================================================================================
  [GRAPHIQUE] DataInsight Pro — Plateforme Professionnelle d'Analyse de Données
  PARTIE 1 — SETUP DU PROJET & DÉCOUVERTE DU DATASET
================================================================================

"La data sans structure, c'est du bruit. La structure sans data, c'est du vide."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER RÉEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ENTREPRISE : EuroShop Commerce S.A.S.
Secteur    : E-commerce B2C et B2B multinationale européenne
Taille     : 350 employés, 2 500 clients actifs, 8 pays
CA annuel  : ~12 millions €

PROBLÉMATIQUE BUSINESS :
  EuroShop Commerce a accumulé 2 ans de données transactionnelles (2022-2023)
  dans son CRM et son système de facturation. Le directeur commercial demande :

  ① "Quels sont nos produits et catégories les plus rentables ?"
  ② "Dans quels pays et régions nos ventes sont-elles les meilleures ?"
  ③ "Quels clients achètent le plus ? Y a-t-il des patterns de fidélité ?"
  ④ "Y a-t-il des anomalies dans nos données (erreurs de saisie) ?"
  ⑤ "Comment nos ventes évoluent-elles dans le temps ?"

  En tant que data scientist junior recruté, votre mission est de construire
  un pipeline d'analyse complet et de produire un rapport executive.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

COMPÉTENCES VISÉES :
  [OK] Créer une architecture de projet data professionnelle
  [OK] Installer et configurer un environnement Python dédié
  [OK] Charger et inspecter un fichier Excel réel avec pandas
  [OK] Comprendre la signification métier de chaque colonne
  [OK] Identifier les problèmes de qualité des données au premier regard
  [OK] Rédiger une documentation technique claire

CONCEPTS COUVERTS :
  -> Structure de projet (src/, data/, notebooks/, reports/)
  -> Virtual environments (venv)
  -> requirements.txt
  -> pandas.read_excel()
  -> df.info(), df.describe(), df.head()
  -> Audit initial des données manquantes

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  DATASET : real_dataset.xlsx
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FICHIER : data/real_dataset.xlsx
SOURCE  : Données transactionnelles EuroShop Commerce 2022-2023
          (Inspiré du dataset UCI Online Retail II — open data)
          -> https://archive.ics.uci.edu/dataset/502/online+retail+ii

FEUILLES EXCEL :
  • raw_data        -> 12 000 lignes × 13 colonnes (données brutes)
  • data_dictionary -> Dictionnaire des données (métadonnées)
  • kpi_summary     -> Tableau de bord KPI (à compléter)

DESCRIPTION DES 13 COLONNES :
┌─────────────────────┬────────────┬───────────────────────────────────────────┐
│ Colonne             │ Type       │ Description métier                        │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ invoice_id          │ TEXT       │ Identifiant unique de chaque commande     │
│                     │            │ Format : INV-XXXXXX                       │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ customer_id         │ TEXT       │ Identifiant du client                     │
│                     │            │ Format : CUST-XXXX (2 500 clients)        │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ date                │ DATE       │ Date de la commande                       │
│                     │            │ Format : YYYY-MM-DD                       │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ country             │ TEXT       │ Pays de livraison (10 pays européens)     │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ region              │ TEXT       │ Région administrative du pays             │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ category            │ TEXT       │ Catégorie du produit (8 catégories)       │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ product_name        │ TEXT       │ Nom complet du produit (64 référencés)    │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ unit_price          │ FLOAT      │ Prix unitaire HT en euros                 │
│                     │            │ [ATTENTION] Peut contenir des négatifs (erreurs)   │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ quantity            │ INTEGER    │ Quantité commandée                        │
│                     │            │ [ATTENTION] NaN (~2%), zéros (~5 lignes)           │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ discount_pct        │ FLOAT      │ Taux de remise (0.0 = sans remise)        │
│                     │            │ [ATTENTION] NaN (~3%), plage [0.0 – 0.20]          │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ payment_method      │ TEXT       │ Moyen de paiement utilisé                 │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ customer_segment    │ TEXT       │ Segment commercial du client              │
├─────────────────────┼────────────┼───────────────────────────────────────────┤
│ order_status        │ TEXT       │ Statut de la commande                     │
│                     │            │ Livré / En cours / Annulé / Retourné      │
└─────────────────────┴────────────┴───────────────────────────────────────────┘

PROBLÈMES CONNUS (à corriger en Partie 3) :
  [ATTENTION]  Prix négatifs : ~10 lignes (erreurs de saisie)
  [ATTENTION]  Quantité NaN  : ~253 lignes
  [ATTENTION]  Remise NaN    : ~383 lignes
  [ATTENTION]  Quantité = 0  : ~5 lignes
  [ATTENTION]  Dates futures : ~8 lignes (2025-03-15 dans des données 2022-2023)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  THÉORIE APPLIQUÉE — STRUCTURE D'UN PROJET DATA PROFESSIONNEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

PRINCIPE DE SÉPARATION DES RESPONSABILITÉS (SoC) :
  Chaque fichier a UNE et UNE SEULE responsabilité.
  C'est le principe fondamental du génie logiciel appliqué à la data.

ARCHITECTURE DU PROJET :

python_data_project/
│
├── data/
│   └── real_dataset.xlsx      <- Données brutes ORIGINALES (ne jamais modifier!)
│
├── notebooks/
│   └── exploration.ipynb      <- Exploration interactive (prototypage)
│
├── src/                       <- Code source réutilisable (modules Python)
│   ├── __init__.py            <- Marque src/ comme un package Python
│   ├── data_loader.py         <- UNIQUE rôle : charger les données
│   ├── data_cleaning.py       <- UNIQUE rôle : nettoyer les données
│   ├── analysis.py            <- UNIQUE rôle : calculer les métriques
│   ├── visualization.py       <- UNIQUE rôle : créer les graphiques
│   └── utils.py               <- Fonctions utilitaires partagées
│
├── reports/
│   └── final_report.md        <- Rapport exécutif pour le directeur commercial
│
├── requirements.txt           <- Liste des dépendances exactes
├── .gitignore                 <- Fichiers à ignorer dans git
└── main.py                    <- Point d'entrée unique du programme

RÔLE DE CHAQUE FICHIER :

data_loader.py
  -> Responsabilité : charger les données depuis Excel/CSV/API
  -> Ne fait AUCUN calcul, AUCUN nettoyage
  -> Retourne toujours un DataFrame brut

data_cleaning.py
  -> Responsabilité : détecter et corriger les problèmes
  -> Travaille sur une COPIE (jamais l'original)
  -> Documente chaque transformation

analysis.py
  -> Responsabilité : calculer les KPIs et statistiques
  -> Travaille sur des données DÉJÀ nettoyées
  -> Retourne des DataFrames résultats

visualization.py
  -> Responsabilité : créer et sauvegarder les graphiques
  -> Reçoit des DataFrames, produit des images
  -> N'a pas accès aux données brutes

utils.py
  -> Fonctions transversales : logging, formatage, validation
  -> Importé par tous les autres modules

main.py
  -> Orchestre tout le pipeline dans l'ordre
  -> C'est le seul fichier qu'on exécute directement

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION COMPLÈTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

──────────────────────────────────────────────────────
ÉTAPE A : CRÉER L'ENVIRONNEMENT VIRTUEL
──────────────────────────────────────────────────────

Dans votre terminal, depuis le dossier du projet :

```bash
# 1. Créer un environnement virtuel nommé "venv"
python -m venv venv

# 2. L'activer (Linux/Mac)
source venv/bin/activate

# 2. L'activer (Windows PowerShell)
venv\Scripts\Activate.ps1

# 3. Vérifier que c'est actif (le prompt doit afficher "(venv)")
which python   # Linux/Mac
where python   # Windows
```

EXPLICATION :
  python -m venv venv
    python    -> l'interpréteur Python
    -m venv   -> exécuter le module venv (virtual environment)
    venv      -> nom du dossier à créer (convention universelle)

  Pourquoi un venv ?
    -> Isoler les dépendances de CE projet
    -> Éviter les conflits avec d'autres projets
    -> Reproductibilité : même versions partout

──────────────────────────────────────────────────────
ÉTAPE B : requirements.txt
──────────────────────────────────────────────────────

```
# requirements.txt
# DataInsight Pro — Dépendances exactes
# Généré le : 2024-01-01
# Python version : 3.11+

# ── Manipulation de données ──
pandas==2.1.4          # DataFrame, lecture Excel/CSV
numpy==1.26.2          # Calculs numériques vectorisés
openpyxl==3.1.2        # Lecture/écriture fichiers .xlsx

# ── Visualisation ──
matplotlib==3.8.2      # Graphiques de base
seaborn==0.13.0        # Graphiques statistiques
plotly==5.18.0         # Graphiques interactifs

# ── Statistiques & Machine Learning ──
scipy==1.11.4          # Tests statistiques
scikit-learn==1.3.2    # Machine Learning

# ── Utilitaires ──
python-dotenv==1.0.0   # Variables d'environnement
loguru==0.7.2          # Logging amélioré
rich==13.7.0           # Affichage terminal coloré
```

Installation :
```bash
pip install -r requirements.txt
```

──────────────────────────────────────────────────────
ÉTAPE C : src/__init__.py
──────────────────────────────────────────────────────

```python
# src/__init__.py
"""
DataInsight Pro — Package d'analyse de données EuroShop Commerce.

Ce fichier marque le dossier src/ comme un package Python.
Sans ce fichier, Python ne pourrait pas importer les modules de src/.

Usage :
    from src.data_loader import DataLoader
    from src.data_cleaning import DataCleaner
"""

__version__ = "1.0.0"
__author__  = "DataInsight Pro Team"
__project__ = "EuroShop Commerce Analysis"
```

EXPLICATION LIGNE PAR LIGNE :
  __version__ = "1.0.0"
    __version__ -> attribut spécial (dunder = double underscore)
    "1.0.0"     -> versionnement sémantique : majeur.mineur.patch

  Ce fichier peut être vide — son existence suffit à faire de src/ un package.

──────────────────────────────────────────────────────
ÉTAPE D : src/utils.py
──────────────────────────────────────────────────────

```python
# src/utils.py
"""
Fonctions utilitaires partagées par tous les modules DataInsight Pro.

Responsabilité : fournir des outils transversaux (logging, formatage,
validation) sans logique métier.
"""

import logging            # Module standard de Python pour les logs
import os                 # Interaction avec le système de fichiers
from datetime import datetime   # Manipulation des dates
from pathlib import Path        # Chemins de fichiers orientés objet


# ── Configuration du logging ──────────────────────────────────────
def configurer_logging(niveau: str = "INFO", fichier_log: str = None) -> logging.Logger:
    """
    Configure le système de logging du projet.

    Paramètres :
        niveau     : "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
        fichier_log: chemin optionnel vers un fichier de log

    Retourne :
        Logger configuré

    Exemple :
        logger = configurer_logging("DEBUG", "logs/run.log")
        logger.info("Pipeline démarré")
    """
    # Créer un logger nommé "DataInsightPro"
    # Les noms permettent de filtrer les logs par module
    logger = logging.getLogger("DataInsightPro")

    # Définir le niveau : seuls les messages >= niveau seront affichés
    # DEBUG(10) < INFO(20) < WARNING(30) < ERROR(40) < CRITICAL(50)
    logger.setLevel(getattr(logging, niveau.upper()))

    # Formateur : définit l'apparence des messages de log
    # %(asctime)s  -> date et heure
    # %(name)s     -> nom du logger
    # %(levelname)s-> niveau (INFO, WARNING...)
    # %(message)s  -> le message
    formateur = logging.Formatter(
        fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S"
    )

    # Handler console : affiche dans le terminal
    handler_console = logging.StreamHandler()
    handler_console.setFormatter(formateur)
    logger.addHandler(handler_console)

    # Handler fichier (optionnel) : écrit dans un fichier
    if fichier_log:
        # Créer le dossier parent si nécessaire
        Path(fichier_log).parent.mkdir(parents=True, exist_ok=True)
        handler_fichier = logging.FileHandler(fichier_log, encoding="utf-8")
        handler_fichier.setFormatter(formateur)
        logger.addHandler(handler_fichier)

    return logger


# ── Validation des chemins ─────────────────────────────────────────
def verifier_fichier_existe(chemin: str) -> Path:
    """
    Vérifie qu'un fichier existe et retourne son chemin absolu.

    Paramètre :
        chemin : chemin relatif ou absolu vers le fichier

    Retourne :
        Objet Path (chemin absolu)

    Lève :
        FileNotFoundError si le fichier n'existe pas
    """
    # Path(chemin) -> convertit la chaîne en objet Path (cross-platform)
    chemin_path = Path(chemin)

    # .resolve() -> retourne le chemin ABSOLU
    # Exemple : "data/fichier.xlsx" -> "/home/user/projet/data/fichier.xlsx"
    chemin_absolu = chemin_path.resolve()

    # .exists() -> True si le fichier ou dossier existe
    if not chemin_absolu.exists():
        raise FileNotFoundError(
            f"[X] Fichier introuvable : {chemin_absolu}\n"
            f"   Vérifiez que vous avez bien placé real_dataset.xlsx dans data/"
        )

    return chemin_absolu


# ── Formatage des valeurs ──────────────────────────────────────────
def formater_euros(valeur: float, decimales: int = 2) -> str:
    """
    Formate un nombre en euros lisible.

    Exemple :
        formater_euros(1234567.89) -> "1 234 567,89 €"
        formater_euros(42)         -> "42,00 €"
    """
    # f-string avec formatage :,.2f
    # ,  -> séparateur des milliers (virgule en anglais)
    # .2f-> 2 décimales
    # On remplace ensuite , par espace et . par ,  (format français)
    valeur_formatee = f"{valeur:,.{decimales}f}"
    valeur_formatee = valeur_formatee.replace(",", " ").replace(".", ",")
    return f"{valeur_formatee} €"


def formater_pourcentage(valeur: float, decimales: int = 1) -> str:
    """
    Formate un float en pourcentage lisible.

    Exemple :
        formater_pourcentage(0.1234) -> "12,3%"
        formater_pourcentage(1.0)    -> "100,0%"
    """
    return f"{valeur * 100:.{decimales}f}%".replace(".", ",")


# ── Horodatage ────────────────────────────────────────────────────
def horodatage_fichier() -> str:
    """
    Retourne un horodatage utilisable dans un nom de fichier.
    Exemple : "20240115_143022"
    """
    # datetime.now() -> date et heure actuelles
    # .strftime() -> formater selon un pattern
    # %Y = année 4 chiffres, %m = mois, %d = jour
    # %H = heure (24h), %M = minute, %S = seconde
    return datetime.now().strftime("%Y%m%d_%H%M%S")


# ── Créer les dossiers nécessaires ────────────────────────────────
def initialiser_dossiers(dossiers: list) -> None:
    """
    Crée les dossiers s'ils n'existent pas.

    Paramètre :
        dossiers : liste de chemins à créer

    Exemple :
        initialiser_dossiers(["data", "reports", "outputs"])
    """
    for dossier in dossiers:
        # parents=True -> crée les parents si nécessaires
        # exist_ok=True -> ne lève pas d'erreur si le dossier existe déjà
        Path(dossier).mkdir(parents=True, exist_ok=True)


# ── Affichage d'un séparateur visuel ──────────────────────────────
def afficher_section(titre: str, largeur: int = 65) -> None:
    """
    Affiche un titre encadré dans le terminal.

    Exemple :
        afficher_section("CHARGEMENT DES DONNÉES")
        ══════════════════════════════════════════════════════════════
        CHARGEMENT DES DONNÉES
        ══════════════════════════════════════════════════════════════
    """
    separateur = "═" * largeur
    print(f"\n{separateur}")
    print(f"  {titre.upper()}")
    print(separateur)
```

──────────────────────────────────────────────────────
ÉTAPE E : src/data_loader.py
──────────────────────────────────────────────────────

```python
# src/data_loader.py
"""
Module de chargement des données EuroShop Commerce.

Responsabilité UNIQUE : lire les fichiers de données et retourner
des DataFrames pandas bruts, sans aucune transformation.

Principe : ce module ne doit JAMAIS modifier les données.
           Il lit et retourne, c'est tout.
"""

import pandas as pd          # Bibliothèque principale de manipulation de données
import numpy as np           # Calculs numériques (utilisé pour les types)
from pathlib import Path      # Gestion des chemins cross-platform
import logging               # Système de logs
from typing import Optional   # Annotations de types optionnels

# Récupérer le logger configuré dans utils.py
# logging.getLogger() retourne toujours le MÊME logger si le nom est identique
logger = logging.getLogger("DataInsightPro")


class DataLoader:
    """
    Classe responsable du chargement des données.

    Pourquoi une classe et pas juste des fonctions ?
    -> Une classe permet de stocker la configuration (chemin, feuille)
    -> On peut l'instancier une fois et réutiliser partout
    -> Facilite les tests unitaires

    Attributs :
        chemin_excel : chemin vers le fichier real_dataset.xlsx
        feuille      : nom de la feuille à lire (par défaut "raw_data")
    """

    def __init__(self, chemin_excel: str, feuille: str = "raw_data"):
        """
        Initialise le DataLoader.

        __init__ est le constructeur : appelé automatiquement à la création
        DataLoader("data/real_dataset.xlsx")

        Paramètres :
            chemin_excel : chemin vers le fichier Excel
            feuille      : nom de la feuille Excel à lire
        """
        # self.chemin -> stocke la valeur comme attribut de l'instance
        # Path(chemin_excel) -> convertit en objet Path (plus robuste que str)
        self.chemin = Path(chemin_excel)

        # str de la feuille à lire dans Excel
        self.feuille = feuille

        # Variable pour stocker le DataFrame une fois chargé
        # None signifie "pas encore chargé"
        self._df_brut: Optional[pd.DataFrame] = None

        # Log de confirmation
        logger.info(f"DataLoader initialisé -> {self.chemin}")

    def charger(self) -> pd.DataFrame:
        """
        Charge le fichier Excel et retourne le DataFrame brut.

        Retourne :
            pd.DataFrame avec les données brutes (non nettoyées)

        Lève :
            FileNotFoundError si le fichier n'existe pas
            ValueError si la feuille n'est pas trouvée
        """
        # Vérification de l'existence du fichier
        if not self.chemin.exists():
            raise FileNotFoundError(
                f"[X] Fichier non trouvé : {self.chemin}\n"
                f"   Placez real_dataset.xlsx dans le dossier data/"
            )

        logger.info(f"Chargement de '{self.feuille}' depuis {self.chemin.name}...")

        # pd.read_excel() : lit un fichier Excel dans un DataFrame
        # Paramètres importants :
        #   sheet_name   -> nom ou index de la feuille
        #   dtype        -> forcer le type de certaines colonnes
        #   parse_dates  -> convertir automatiquement en datetime
        #   na_values    -> valeurs à considérer comme NaN
        self._df_brut = pd.read_excel(
            self.chemin,
            sheet_name=self.feuille,
            dtype={
                "invoice_id":       str,    # Garder comme texte (pas de conversion numérique)
                "customer_id":      str,    # Idem
                "country":          str,
                "region":           str,
                "category":         str,
                "product_name":     str,
                "payment_method":   str,
                "customer_segment": str,
                "order_status":     str,
            },
            # unit_price, quantity, discount_pct -> laissés en float (détection auto)
        )

        # Log des informations de chargement
        lignes, colonnes = self._df_brut.shape
        logger.info(f"[OK] Chargé : {lignes:,} lignes × {colonnes} colonnes")

        # Retourner une COPIE pour protéger les données originales
        # .copy() crée une copie indépendante — modifier la copie ne touche pas l'original
        return self._df_brut.copy()

    def charger_dictionnaire(self) -> pd.DataFrame:
        """
        Charge la feuille 'data_dictionary' (métadonnées des colonnes).

        Retourne :
            DataFrame avec le dictionnaire des données
        """
        logger.info("Chargement du dictionnaire de données...")
        return pd.read_excel(self.chemin, sheet_name="data_dictionary")

    def apercu_rapide(self, df: pd.DataFrame) -> None:
        """
        Affiche un aperçu rapide du DataFrame dans le terminal.

        Paramètre :
            df : DataFrame à inspecter
        """
        print("\n" + "="*65)
        print("  APERÇU RAPIDE DU DATASET")
        print("="*65)

        # .shape -> tuple (nombre_lignes, nombre_colonnes)
        print(f"\n[MESURE] Dimensions   : {df.shape[0]:,} lignes × {df.shape[1]} colonnes")

        # .memory_usage(deep=True) -> mémoire utilisée par chaque colonne
        # .sum() -> total en octets
        # / 1024**2 -> convertir en mégaoctets
        memoire_mb = df.memory_usage(deep=True).sum() / 1024**2
        print(f"[SAUVEGARDE] Mémoire      : {memoire_mb:.2f} MB")

        # .dtypes -> Series avec le type de chaque colonne
        print(f"\n[LISTE] Types de données :")
        for col, dtype in df.dtypes.items():
            # .isnull().sum() -> nombre de NaN dans cette colonne
            nan_count = df[col].isnull().sum()
            nan_info = f"  [ATTENTION] {nan_count} NaN" if nan_count > 0 else ""
            print(f"   {col:20s} : {str(dtype):10s}{nan_info}")

        # .head(3) -> les 3 premières lignes
        print(f"\n[RECHERCHE] Premières lignes :")
        print(df.head(3).to_string())

        # .tail(2) -> les 2 dernières lignes
        print(f"\n[RECHERCHE] Dernières lignes :")
        print(df.tail(2).to_string())
```

──────────────────────────────────────────────────────
ÉTAPE F : main.py (version partielle — Partie 1)
──────────────────────────────────────────────────────

```python
# main.py
"""
DataInsight Pro — Point d'entrée principal.

Ce fichier orchestre l'ensemble du pipeline d'analyse.
Il importe et appelle les modules dans l'ordre logique.

Exécution :
    python main.py
"""

# ── Imports de la bibliothèque standard Python ──
import sys          # Accès aux arguments et infos système
import os           # Interaction avec l'OS

# ── Imports de nos modules (src/) ──
from src.utils import configurer_logging, afficher_section, initialiser_dossiers
from src.data_loader import DataLoader

# ═══════════════════════════════════════════════════════════════
# CONFIGURATION GLOBALE
# ═══════════════════════════════════════════════════════════════

# Chemins des dossiers (relatifs à main.py)
CHEMIN_DONNEES   = "data/real_dataset.xlsx"
DOSSIER_RAPPORTS = "reports"
DOSSIER_OUTPUTS  = "outputs"

# ═══════════════════════════════════════════════════════════════
# POINT D'ENTRÉE PRINCIPAL
# ═══════════════════════════════════════════════════════════════

def main():
    """
    Fonction principale — orchestre tout le pipeline.

    Convention Python : la fonction main() est le point d'entrée.
    Elle est appelée par `if __name__ == "__main__":` ci-dessous.
    """

    # 1. Configurer le logging
    logger = configurer_logging(niveau="INFO")
    afficher_section("DataInsight Pro — EuroShop Commerce Analysis")
    logger.info("Démarrage du pipeline...")

    # 2. Créer les dossiers nécessaires
    initialiser_dossiers([DOSSIER_RAPPORTS, DOSSIER_OUTPUTS])

    # 3. Charger les données
    afficher_section("Chargement des données")
    loader = DataLoader(CHEMIN_DONNEES)    # Instancier le DataLoader
    df_brut = loader.charger()             # Charger le fichier Excel
    loader.apercu_rapide(df_brut)          # Afficher l'aperçu

    logger.info("Partie 1 terminée [OK]")
    return df_brut    # Retourner pour les prochaines parties


# ── Pattern Python standard ──────────────────────────────────
# __name__ == "__main__" -> True seulement si on exécute ce fichier directement
# Si ce fichier est importé par un autre, __name__ sera "main" (pas "__main__")
# -> Protège le code d'une exécution accidentelle lors d'un import
if __name__ == "__main__":
    df = main()
    print(f"\n[OK] Dataset chargé avec succès : {df.shape[0]:,} lignes")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  EXPLICATION LIGNE PAR LIGNE — CONCEPTS CLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LIGNE : import pandas as pd
  import    -> directive d'importation Python
  pandas    -> bibliothèque de manipulation de données
  as pd     -> alias raccourci (convention universelle, TOUJOURS "pd")
  Résultat  -> on peut écrire pd.read_excel() au lieu de pandas.read_excel()

LIGNE : from pathlib import Path
  from pathlib -> du module pathlib (standard Python 3.4+)
  import Path  -> importer uniquement la classe Path
  Path("data/fichier.xlsx") -> objet plus robuste qu'une chaîne de texte
  Path fonctionne identiquement sur Windows (\ ) et Linux/Mac (/)

LIGNE : self._df_brut: Optional[pd.DataFrame] = None
  self            -> l'instance courante de la classe
  _df_brut        -> attribut privé (convention : _ préfixe = interne)
  Optional[...]   -> peut être None OU un DataFrame (annotation de type)
  = None          -> valeur initiale (pas encore chargé)

LIGNE : pd.read_excel(self.chemin, sheet_name=self.feuille, dtype={...})
  pd.read_excel() -> fonction qui lit un fichier Excel
  sheet_name      -> quel onglet lire ("raw_data")
  dtype={"col":str} -> forcer le type STRING pour éviter la conversion auto
  Retourne        -> pd.DataFrame

LIGNE : return self._df_brut.copy()
  .copy()  -> crée une copie INDÉPENDANTE du DataFrame
  Pourquoi ? Si l'appelant modifie son DataFrame, l'original reste intact
  Sans .copy() -> modification de l'un modifie l'autre (partage de mémoire)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  ANALYSE ET INTERPRÉTATION DU DATASET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

WHAT THE DATA TELLS US (premier coup d'œil) :

1. VOLUMÉTRIE : 12 000 transactions sur 2 ans = ~500 transactions/mois
   -> Taille représentative d'une PME e-commerce active

2. CLIENTS : ~2 500 clients uniques sur 12 000 transactions
   -> Moyenne de 4,8 commandes par client sur 2 ans = fidélité modérée

3. COUVERTURE GÉOGRAPHIQUE : 10 pays européens
   -> La France domine (28%), l'Allemagne suit (18%)
   -> Présence dans toute l'Europe occidentale

4. DONNÉES MANQUANTES :
   - quantity (253 NaN)    -> ~2.1% des commandes sans quantité
   - discount_pct (383 NaN)-> ~3.2% des remises non renseignées
   -> Ces NaN sont des MAR (Missing At Random) — liés à des bugs systèmes

5. ERREURS DE QUALITÉ :
   - Prix négatifs : probablement des avoirs/remboursements mal encodés
   - Dates 2025   : erreurs de saisie ou données de test
   - Quantité = 0 : commandes annulées avant traitement ?

INSIGHT BUSINESS IMMÉDIAT :
  "Avant toute analyse, il faut traiter ~650 lignes problématiques
   (~5.4% du dataset). Ce chiffre est acceptable pour un dataset réel."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8⃣  BONNES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] NE JAMAIS MODIFIER real_dataset.xlsx
   Le fichier original est sacré. Toutes les transformations se font
   en mémoire (DataFrame) ou dans de nouveaux fichiers.

[OK] TOUJOURS VERSIONNER LE CODE (git)
   git init
   git add .
   git commit -m "feat: initialisation projet DataInsight Pro"

[OK] NOMMER LES COLONNES EN SNAKE_CASE
   Bon    : customer_id, unit_price, order_status
   Mauvais: CustomerID, UnitPrice, OrderStatus

[OK] ÉCRIRE DES DOCSTRINGS SUR TOUTES LES FONCTIONS
   -> Permet à un autre développeur (ou à vous dans 6 mois) de comprendre

[OK] UTILISER DES CONSTANTES POUR LES VALEURS MAGIQUES
   Bon    : DATE_MIN = "2022-01-01"
   Mauvais: if date > "2022-01-01":

[OK] AJOUTER LE .gitignore
   ```
   # .gitignore
   venv/
   __pycache__/
   *.pyc
   .env
   outputs/
   *.log
   ```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9⃣  ERREURS FRÉQUENTES DES DÉBUTANTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ERREUR 1 : Modifier le fichier original
  [X]  df = pd.read_excel("data/real_dataset.xlsx")
      df.dropna(inplace=True)   # Modifie df EN PLACE
      df.to_excel("data/real_dataset.xlsx")  # ÉCRASE L'ORIGINAL !

  [OK]  df_brut = pd.read_excel("data/real_dataset.xlsx")
      df_clean = df_brut.copy()  # Travailler sur une copie
      df_clean = df_clean.dropna()

ERREUR 2 : Oublier sheet_name
  [X]  df = pd.read_excel("data/real_dataset.xlsx")
      # Lit le PREMIER onglet par défaut (peut être "kpi_summary" !)

  [OK]  df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")

ERREUR 3 : Confondre NaN et 0
  [X]  df['quantity'].fillna(0)
      # Un NaN dans quantity signifie "inconnue", pas "zéro" !

  [OK]  Analyser D'ABORD pourquoi la valeur manque, puis décider.

ERREUR 4 : Chemins absolus dans le code
  [X]  df = pd.read_excel("/Users/marie/Documents/projet/data/real_dataset.xlsx")
      # Ne fonctionnera pas sur l'ordinateur d'un collègue !

  [OK]  chemin = Path(__file__).parent.parent / "data" / "real_dataset.xlsx"
      df = pd.read_excel(chemin)

ERREUR 5 : Oublier l'encodage UTF-8 pour les CSV
  [X]  pd.read_csv("fichier.csv")
      # Peut planter sur les accents français (é, è, ç...)

  [OK]  pd.read_csv("fichier.csv", encoding="utf-8-sig")

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PARTIE 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────

Ex 1.1 : Créez l'arborescence complète du projet à la main (mkdir + touch).
  Vérifiez avec : find python_data_project -type f

Ex 1.2 : Chargez real_dataset.xlsx avec pandas et affichez :
  - Le nombre de lignes et colonnes
  - Les 5 premières lignes
  - La liste des colonnes et leurs types

Ex 1.3 : Chargez la feuille "data_dictionary" et affichez-la complètement.

── NIVEAU INTERMÉDIAIRE ────────────────────────────────────────────

Ex 1.4 : Écrivez une fonction `auditer_dataset(df)` qui retourne un
  dictionnaire avec :
  - nombre de lignes
  - nombre de colonnes
  - nombre de valeurs manquantes par colonne
  - nombre de doublons
  - types des colonnes

Ex 1.5 : Ajoutez au DataLoader une méthode `charger_toutes_feuilles()`
  qui retourne un dict {"nom_feuille": DataFrame} pour toutes les feuilles.

── NIVEAU AVANCÉ ────────────────────────────────────────────────────

Ex 1.6 : Implémentez un système de cache dans DataLoader :
  Si le fichier a déjà été chargé (self._df_brut is not None),
  retourner directement la copie sans relire le fichier Excel.
  Mesurez le gain de performance avec time.time().

Ex 1.7 : Créez un fichier config.yaml contenant les chemins du projet
  et chargez-le dans main.py avec PyYAML.
  Avantage : on ne hardcode plus les chemins dans le code.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉS DÉTAILLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# ── Corrigé Ex 1.2 ──────────────────────────────────────────────
import pandas as pd
from pathlib import Path

# Charger le fichier
# Path(__file__) -> chemin absolu de CE script
# .parent -> dossier parent
# / "data" -> ajouter "data" au chemin (opérateur / de pathlib)
chemin = Path("data/real_dataset.xlsx")
df = pd.read_excel(chemin, sheet_name="raw_data")

# Afficher les dimensions
# df.shape -> tuple (lignes, colonnes)
print(f"Dimensions : {df.shape[0]:,} lignes × {df.shape[1]} colonnes")

# Afficher les premières lignes
# .head(5) -> les 5 premières lignes (5 est la valeur par défaut)
print("\n5 premières lignes :")
print(df.head(5).to_string())  # .to_string() -> affiche TOUT sans troncature

# Afficher colonnes et types
# .dtypes -> Series avec les types de chaque colonne
print("\nColonnes et types :")
for col, dtype in df.dtypes.items():
    # items() -> itère sur (nom_colonne, type)
    print(f"  {col:25s} : {dtype}")


# ── Corrigé Ex 1.4 ──────────────────────────────────────────────
def auditer_dataset(df: pd.DataFrame) -> dict:
    """
    Retourne un audit complet d'un DataFrame.
    """
    # dict comprehension : {clé: valeur for élément in itérable}
    nan_par_colonne = {
        col: int(df[col].isnull().sum())
        for col in df.columns
        # int() car numpy int n'est pas sérialisable en JSON
    }

    return {
        "lignes":           df.shape[0],
        "colonnes":         df.shape[1],
        "nan_par_colonne":  nan_par_colonne,
        "total_nan":        sum(nan_par_colonne.values()),
        "doublons":         int(df.duplicated().sum()),
        "types_colonnes":   {col: str(dtype) for col, dtype in df.dtypes.items()},
    }

# Appel et affichage
import json
audit = auditer_dataset(df)
print(json.dumps(audit, indent=2, ensure_ascii=False))


# ── Corrigé Ex 1.6 ── Cache dans DataLoader ─────────────────────
import time

class DataLoaderAvecCache:
    def __init__(self, chemin: str):
        self.chemin = Path(chemin)
        self._df_brut = None        # None = pas encore chargé
        self._temps_chargement = None

    def charger(self) -> pd.DataFrame:
        # Si déjà chargé -> retourner la copie directement
        if self._df_brut is not None:
            print("[OK] Cache utilisé (chargement instantané)")
            return self._df_brut.copy()

        # Sinon -> charger depuis le fichier
        debut = time.time()
        self._df_brut = pd.read_excel(self.chemin, sheet_name="raw_data")
        self._temps_chargement = time.time() - debut
        print(f"[DOSSIER] Fichier chargé en {self._temps_chargement:.2f}s")
        return self._df_brut.copy()

# Test du cache
loader_cache = DataLoaderAvecCache("data/real_dataset.xlsx")
df1 = loader_cache.charger()   # Lit le fichier -> lent
df2 = loader_cache.charger()   # Utilise le cache -> rapide
```

================================================================================
FIN PARTIE 1 — Prochaine étape : Partie 2 — Chargement avancé des données
================================================================================

================================================================================
  [GRAPHIQUE] DataInsight Pro — Plateforme Professionnelle d'Analyse de Données
  PARTIE 2 — CHARGEMENT AVANCÉ DES DONNÉES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le directeur technique d'EuroShop Commerce vous informe que :
  -> Les données proviennent de 3 sources différentes (ERP, CRM, logistique)
  -> Elles arrivent parfois en CSV, parfois en Excel, parfois en JSON
  -> Certaines colonnes ont des types incorrects (dates en texte, prix en string)
  -> Le fichier peut être très volumineux en production (>1 million de lignes)

Votre mission : construire un DataLoader robuste qui gère tous ces cas.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [OK] Inspecter un DataFrame en profondeur (types, NaN, doublons, stats)
  [OK] Convertir les types de colonnes correctement (dates, nombres, catégories)
  [OK] Gérer les types catégoriels pour économiser la mémoire
  [OK] Valider les données chargées avec des assertions
  [OK] Mesurer et optimiser les performances de chargement

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION COMPLÈTE — data_loader.py (version avancée)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# src/data_loader.py  (version complète Partie 2)
"""
Module de chargement robuste des données EuroShop Commerce.

Gère :
  - Lecture Excel multi-feuilles
  - Conversion des types de colonnes
  - Optimisation mémoire (types catégoriels)
  - Validation après chargement
  - Rapport d'audit détaillé
"""

import pandas as pd
import numpy as np
from pathlib import Path
import logging
import time
from typing import Optional, Dict, List

logger = logging.getLogger("DataInsightPro")


# ── Constantes métier (valeurs de référence) ─────────────────────
COLONNES_ATTENDUES = [
    "invoice_id", "customer_id", "date", "country", "region",
    "category", "product_name", "unit_price", "quantity",
    "discount_pct", "payment_method", "customer_segment", "order_status"
]

CATEGORIES_VALIDES = {
    "category": [
        "Électronique", "Vêtements", "Alimentation",
        "Maison & Jardin", "Sports & Loisirs",
        "Beauté & Santé", "Livres & Médias", "Jouets & Enfants"
    ],
    "order_status": ["Livré", "En cours", "Annulé", "Retourné", "En attente"],
    "payment_method": [
        "Carte de crédit", "PayPal", "Virement bancaire",
        "Carte de débit", "Apple Pay", "Google Pay"
    ],
    "customer_segment": ["Particulier", "PME", "Grande entreprise", "Administration"],
}

DATE_MIN = "2022-01-01"   # Première date valide
DATE_MAX = "2023-12-31"   # Dernière date valide


class DataLoader:
    """
    Chargeur de données robuste pour EuroShop Commerce.
    Gère le chargement, la conversion des types et la validation.
    """

    def __init__(self, chemin_excel: str, feuille: str = "raw_data"):
        self.chemin  = Path(chemin_excel)
        self.feuille = feuille
        self._df_brut: Optional[pd.DataFrame] = None
        self._rapport_audit: Optional[Dict] = None
        logger.info(f"DataLoader prêt -> {self.chemin.name}")

    # ─────────────────────────────────────────────────────────────
    # MÉTHODE 1 : CHARGEMENT BRUT
    # ─────────────────────────────────────────────────────────────
    def charger(self) -> pd.DataFrame:
        """
        Charge le fichier Excel en forçant les types corrects.
        Retourne le DataFrame brut (avant nettoyage).
        """
        if not self.chemin.exists():
            raise FileNotFoundError(f"Fichier introuvable : {self.chemin}")

        debut = time.perf_counter()   # Chronomètre haute précision
        logger.info(f"Lecture de '{self.feuille}' ...")

        # ── Lecture avec types explicites ──────────────────────
        # On force str sur les colonnes texte pour éviter que pandas
        # ne convertisse "CUST-0042" en nombre ou "NaN" en float.
        self._df_brut = pd.read_excel(
            self.chemin,
            sheet_name=self.feuille,
            dtype={
                "invoice_id":       "string",    # Type "string" pandas (nullable)
                "customer_id":      "string",
                "country":          "string",
                "region":           "string",
                "category":         "string",
                "product_name":     "string",
                "payment_method":   "string",
                "customer_segment": "string",
                "order_status":     "string",
                # unit_price, quantity, discount_pct -> float64 (auto)
                # date -> object (on va le convertir manuellement)
            },
        )

        duree = time.perf_counter() - debut
        logger.info(f"[OK] Chargé en {duree:.2f}s : {self._df_brut.shape[0]:,} lignes")
        return self._df_brut.copy()

    # ─────────────────────────────────────────────────────────────
    # MÉTHODE 2 : CONVERSION DES TYPES
    # ─────────────────────────────────────────────────────────────
    def convertir_types(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Convertit les colonnes dans leurs types appropriés.

        Transformations :
          - date         : str -> datetime64
          - category etc : str -> Categorical (économie mémoire)
          - unit_price   : float -> float32 (économie mémoire)

        Paramètre :
            df : DataFrame brut

        Retourne :
            DataFrame avec types corrects
        """
        df = df.copy()   # Travailler sur une copie !

        # ── Conversion de la date ──────────────────────────────
        # pd.to_datetime() convertit des chaînes de texte en datetime
        # errors='coerce' -> remplace les dates invalides par NaT (Not a Time)
        #   au lieu de lever une exception
        # NaT est l'équivalent de NaN pour les dates
        logger.info("Conversion de la colonne 'date' en datetime...")
        df["date"] = pd.to_datetime(df["date"], errors="coerce")
        # errors='coerce' -> "2025-03-15" (hors plage) sera conservé
        #                    mais "abc" deviendra NaT

        n_nat = df["date"].isnull().sum()
        if n_nat > 0:
            logger.warning(f"  [ATTENTION] {n_nat} dates invalides -> NaT")

        # ── Conversion en Categorical ──────────────────────────
        # Les colonnes avec peu de valeurs uniques (country, category...)
        # sont plus efficaces stockées comme "Categorical"
        # Exemple : "France" stocké comme entier 0, avec mapping 0->"France"
        # Gain mémoire : jusqu'à 10× sur ces colonnes !
        colonnes_cat = [
            "country", "region", "category", "payment_method",
            "customer_segment", "order_status"
        ]
        logger.info("Conversion des colonnes texte en Categorical...")
        for col in colonnes_cat:
            if col in df.columns:
                # .astype("category") -> type Categorical pandas
                df[col] = df[col].astype("category")
                n_cat = df[col].nunique()
                logger.debug(f"  {col} : {n_cat} catégories")

        # ── Réduction du type numérique ────────────────────────
        # float64 -> float32 : divise par 2 la mémoire pour les prix
        # Précision : float32 = 7 décimales, largement suffisant pour des €
        if "unit_price" in df.columns:
            df["unit_price"] = df["unit_price"].astype("float32")

        if "discount_pct" in df.columns:
            df["discount_pct"] = df["discount_pct"].astype("float32")

        # quantity peut rester float64 car contient des NaN
        # (les entiers numpy ne supportent pas NaN, sauf Int64 nullable)
        if "quantity" in df.columns:
            # Int64 (majuscule) = entier nullable (supporte NaN)
            df["quantity"] = df["quantity"].astype("Int64")

        logger.info("[OK] Conversion des types terminée")
        return df

    # ─────────────────────────────────────────────────────────────
    # MÉTHODE 3 : AUDIT COMPLET
    # ─────────────────────────────────────────────────────────────
    def auditer(self, df: pd.DataFrame) -> Dict:
        """
        Produit un rapport d'audit complet du DataFrame.

        Analyse :
          - Dimensions et mémoire
          - Types de colonnes
          - Données manquantes (par colonne + total)
          - Doublons
          - Statistiques descriptives des colonnes numériques
          - Valeurs uniques des colonnes catégorielles

        Retourne :
            dict avec toutes les métriques d'audit
        """
        rapport = {}

        # ── Dimensions ────────────────────────────────────────
        lignes, colonnes = df.shape
        rapport["dimensions"] = {
            "lignes":   lignes,
            "colonnes": colonnes
        }

        # Mémoire totale en Mo
        # .memory_usage(deep=True) -> mémoire réelle (y compris strings)
        # .sum() -> total en octets
        memoire_octets = df.memory_usage(deep=True).sum()
        rapport["memoire_mb"] = round(memoire_octets / 1024**2, 3)

        # ── Colonnes manquantes ────────────────────────────────
        # Vérifier si toutes les colonnes attendues sont présentes
        colonnes_manquantes = [c for c in COLONNES_ATTENDUES if c not in df.columns]
        rapport["colonnes_manquantes"] = colonnes_manquantes
        if colonnes_manquantes:
            logger.warning(f"  [ATTENTION] Colonnes absentes : {colonnes_manquantes}")

        # ── Données manquantes (NaN) ───────────────────────────
        nan_counts = df.isnull().sum()
        # Convertir en dict avec uniquement les colonnes ayant des NaN
        rapport["nan_par_colonne"] = {
            col: {
                "count":      int(nan_counts[col]),
                "pourcentage": round(nan_counts[col] / lignes * 100, 2)
            }
            for col in df.columns
            if nan_counts[col] > 0    # Ne garder que les colonnes avec NaN
        }
        rapport["total_nan"] = int(nan_counts.sum())

        # ── Doublons ──────────────────────────────────────────
        # duplicated() -> masque booléen (True = doublon d'une ligne précédente)
        # .sum() -> nombre de doublons
        n_doublons = int(df.duplicated().sum())
        rapport["doublons"] = n_doublons

        # Doublons sur invoice_id (chaque facture doit être unique)
        n_doublons_id = int(df["invoice_id"].duplicated().sum())
        rapport["doublons_invoice_id"] = n_doublons_id

        # ── Statistiques numériques ───────────────────────────
        # .describe() -> count, mean, std, min, 25%, 50%, 75%, max
        # .T -> transposer pour avoir les colonnes en lignes
        cols_num = df.select_dtypes(include=[np.number]).columns.tolist()
        if cols_num:
            stats = df[cols_num].describe()
            # Convertir en dict de dicts
            rapport["stats_numeriques"] = {
                col: {k: round(float(v), 4) for k, v in stats[col].items()}
                for col in stats.columns
            }

        # ── Colonnes catégorielles ────────────────────────────
        cols_cat = df.select_dtypes(include=["category", "string", "object"]).columns.tolist()
        rapport["stats_categoriques"] = {}
        for col in cols_cat:
            n_unique = df[col].nunique()
            top_valeur = df[col].mode().iloc[0] if not df[col].mode().empty else "N/A"
            rapport["stats_categoriques"][col] = {
                "uniques": n_unique,
                "top_valeur": str(top_valeur),
                "top_pct": round(
                    (df[col] == top_valeur).sum() / lignes * 100, 1
                )
            }

        self._rapport_audit = rapport
        return rapport

    # ─────────────────────────────────────────────────────────────
    # MÉTHODE 4 : VALIDATION
    # ─────────────────────────────────────────────────────────────
    def valider(self, df: pd.DataFrame) -> List[str]:
        """
        Valide les règles métier sur le DataFrame.

        Règles vérifiées :
          1. Colonnes attendues présentes
          2. Pas de doublons d'invoice_id
          3. Prix unitaire ≠ 0 (valeurs négatives = erreurs)
          4. Quantité ≥ 0
          5. Remise entre 0 et 1
          6. Dates dans la plage 2022-2023

        Retourne :
            liste de messages d'erreur (vide = tout OK)
        """
        erreurs = []

        # Règle 1 : Colonnes attendues
        for col in COLONNES_ATTENDUES:
            if col not in df.columns:
                erreurs.append(f"CRITIQUE: Colonne '{col}' manquante")

        # Règle 2 : Doublons d'invoice_id
        n_dup = df["invoice_id"].duplicated().sum()
        if n_dup > 0:
            erreurs.append(f"ERREUR: {n_dup} invoice_id en doublon")

        # Règle 3 : Prix négatifs
        if "unit_price" in df.columns:
            prix_neg = (df["unit_price"] < 0).sum()
            if prix_neg > 0:
                erreurs.append(f"AVERTISSEMENT: {prix_neg} prix négatifs")

        # Règle 4 : Quantité nulle ou négative
        if "quantity" in df.columns:
            qte_neg = (df["quantity"] <= 0).sum()
            if qte_neg > 0:
                erreurs.append(f"AVERTISSEMENT: {qte_neg} quantités ≤ 0")

        # Règle 5 : Remise hors [0, 1]
        if "discount_pct" in df.columns:
            remise_invalide = ((df["discount_pct"] < 0) | (df["discount_pct"] > 1)).sum()
            if remise_invalide > 0:
                erreurs.append(f"AVERTISSEMENT: {remise_invalide} remises hors [0,1]")

        # Règle 6 : Dates hors plage
        if "date" in df.columns and pd.api.types.is_datetime64_any_dtype(df["date"]):
            date_min = pd.Timestamp(DATE_MIN)
            date_max = pd.Timestamp(DATE_MAX)
            hors_plage = ((df["date"] < date_min) | (df["date"] > date_max)).sum()
            if hors_plage > 0:
                erreurs.append(f"AVERTISSEMENT: {hors_plage} dates hors 2022-2023")

        if not erreurs:
            logger.info("[OK] Validation réussie — aucune erreur critique")
        else:
            for err in erreurs:
                logger.warning(f"  {err}")

        return erreurs

    # ─────────────────────────────────────────────────────────────
    # MÉTHODE 5 : RAPPORT MÉMOIRE AVANT/APRÈS
    # ─────────────────────────────────────────────────────────────
    def comparer_memoire(self, df_avant: pd.DataFrame, df_apres: pd.DataFrame) -> None:
        """
        Affiche une comparaison de mémoire avant/après conversion des types.
        """
        mem_avant = df_avant.memory_usage(deep=True).sum() / 1024**2
        mem_apres = df_apres.memory_usage(deep=True).sum() / 1024**2
        gain = (mem_avant - mem_apres) / mem_avant * 100

        print(f"\n{'─'*50}")
        print(f"  OPTIMISATION MÉMOIRE")
        print(f"{'─'*50}")
        print(f"  Avant conversion : {mem_avant:.2f} MB")
        print(f"  Après conversion : {mem_apres:.2f} MB")
        print(f"  Gain             : {gain:.1f}% ({mem_avant - mem_apres:.2f} MB économisés)")

        # Détail par colonne
        print(f"\n  Détail par colonne :")
        for col in df_avant.columns:
            m_avant = df_avant[col].memory_usage(deep=True) / 1024
            m_apres = df_apres[col].memory_usage(deep=True) / 1024
            if m_avant != m_apres:
                print(f"    {col:22s} : {m_avant:7.1f} Ko -> {m_apres:7.1f} Ko "
                      f"(−{m_avant-m_apres:.1f} Ko)")

    # ─────────────────────────────────────────────────────────────
    # MÉTHODE 6 : AFFICHAGE RAPPORT
    # ─────────────────────────────────────────────────────────────
    def afficher_rapport(self, rapport: Dict) -> None:
        """Affiche le rapport d'audit de manière lisible dans le terminal."""
        print(f"\n{'═'*65}")
        print(f"  RAPPORT D'AUDIT — DataInsight Pro")
        print(f"{'═'*65}")

        dim = rapport["dimensions"]
        print(f"\n[MESURE] Dimensions   : {dim['lignes']:,} lignes × {dim['colonnes']} colonnes")
        print(f"[SAUVEGARDE] Mémoire      : {rapport['memoire_mb']} MB")
        print(f"[SYNC] Doublons     : {rapport['doublons']}")
        print(f"   -> invoice_id en doublon : {rapport['doublons_invoice_id']}")
        print(f"[ATTENTION]  NaN total    : {rapport['total_nan']:,}")

        if rapport["nan_par_colonne"]:
            print(f"\n  NaN par colonne :")
            for col, info in rapport["nan_par_colonne"].items():
                barre = "█" * int(info["pourcentage"] / 2)
                print(f"    {col:20s} : {info['count']:5d} ({info['pourcentage']:.1f}%) {barre}")

        if rapport.get("stats_numeriques"):
            print(f"\n[GRAPHIQUE] Statistiques numériques :")
            for col, stats in rapport["stats_numeriques"].items():
                print(f"    {col:20s} : min={stats['min']:>10.2f}  "
                      f"moy={stats['mean']:>10.2f}  max={stats['max']:>10.2f}")

        if rapport.get("stats_categoriques"):
            print(f"\n[LABEL]  Colonnes catégorielles :")
            for col, stats in rapport["stats_categoriques"].items():
                print(f"    {col:20s} : {stats['uniques']:3d} valeurs uniques  "
                      f"| Mode: '{stats['top_valeur']}' ({stats['top_pct']}%)")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
INSPECTION APPROFONDIE DU DATASET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# notebooks/exploration.ipynb — Cellule 1 : Inspection approfondie
# (Ces commandes fonctionnent aussi dans un script .py)

import pandas as pd
import numpy as np

# Charger les données
df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")

# ══ 1. INFORMATIONS GÉNÉRALES ════════════════════════════════════
print("═"*60)
print("df.info() — Vue d'ensemble complète")
print("═"*60)
# .info() affiche : nombre de lignes, colonnes, types, mémoire
# C'est la PREMIÈRE commande à lancer sur tout nouveau dataset
df.info()
# Output attendu :
# <class 'pandas.core.frame.DataFrame'>
# RangeIndex: 12000 entries, 0 to 11999
# Data columns (total 13 columns):
#  #   Column            Non-Null Count  Dtype
# ---  ------            --------------  -----
#  0   invoice_id        12000 non-null  object
#  ...

# ══ 2. STATISTIQUES DESCRIPTIVES ═════════════════════════════════
print("\n" + "═"*60)
print("df.describe() — Statistiques des colonnes numériques")
print("═"*60)
# include='all' -> inclure aussi les colonnes texte
# (count, unique, top, freq pour les strings)
print(df.describe(include="all").T)   # .T = transposer (plus lisible)

# ══ 3. TYPES DE DONNÉES ══════════════════════════════════════════
print("\n" + "═"*60)
print("Types de données par colonne")
print("═"*60)
# .dtypes -> Series avec le type Pandas de chaque colonne
# object = string ou mixte (le moins précis)
# float64 = nombre décimal 64 bits
# int64 = entier 64 bits
print(df.dtypes)

# ══ 4. DONNÉES MANQUANTES ════════════════════════════════════════
print("\n" + "═"*60)
print("Données manquantes — Analyse complète")
print("═"*60)

# .isnull() -> DataFrame booléen (True = NaN)
# .sum() -> nombre de True par colonne
nan_counts = df.isnull().sum()

# .mean() -> proportion de True (entre 0 et 1)
nan_pcts   = df.isnull().mean() * 100

# Créer un tableau récapitulatif
rapport_nan = pd.DataFrame({
    "NaN count":  nan_counts,
    "NaN %":      nan_pcts.round(2),
    "Non-NaN":    df.notnull().sum(),
    "Type":       df.dtypes
}).sort_values("NaN count", ascending=False)

print(rapport_nan)
print(f"\nTotal NaN : {nan_counts.sum():,} / {df.size:,} cellules")
print(f"Taux global : {nan_counts.sum() / df.size * 100:.2f}%")

# ══ 5. DOUBLONS ══════════════════════════════════════════════════
print("\n" + "═"*60)
print("Analyse des doublons")
print("═"*60)

# Doublons EXACTS (toutes colonnes identiques)
n_doublons_exacts = df.duplicated().sum()
print(f"Doublons exacts (toutes colonnes) : {n_doublons_exacts}")

# Doublons sur invoice_id uniquement
n_dup_invoice = df["invoice_id"].duplicated().sum()
print(f"invoice_id en doublon            : {n_dup_invoice}")

# Si des doublons invoice_id existent, les afficher
if n_dup_invoice > 0:
    print("\nExemples de doublons invoice_id :")
    ids_dup = df[df["invoice_id"].duplicated(keep=False)]["invoice_id"].unique()[:5]
    print(df[df["invoice_id"].isin(ids_dup)].head(10))

# ══ 6. VALEURS UNIQUES PAR COLONNE ═══════════════════════════════
print("\n" + "═"*60)
print("Valeurs uniques par colonne catégorielle")
print("═"*60)

cols_cat = ["country", "category", "payment_method",
            "customer_segment", "order_status"]
for col in cols_cat:
    valeurs = sorted(df[col].dropna().unique())
    print(f"\n  {col} ({len(valeurs)} valeurs) :")
    for v in valeurs:
        count = (df[col] == v).sum()
        pct   = count / len(df) * 100
        barre = "█" * int(pct / 2)
        print(f"    {str(v):30s} : {count:5d} ({pct:5.1f}%) {barre}")

# ══ 7. DISTRIBUTION DES VARIABLES NUMÉRIQUES ═════════════════════
print("\n" + "═"*60)
print("Distribution des variables numériques")
print("═"*60)

for col in ["unit_price", "quantity", "discount_pct"]:
    s = df[col].dropna()
    print(f"\n  {col} :")
    print(f"    count  : {len(s):,}")
    print(f"    mean   : {s.mean():>10.4f}")
    print(f"    median : {s.median():>10.4f}")
    print(f"    std    : {s.std():>10.4f}")
    print(f"    min    : {s.min():>10.4f}")
    print(f"    max    : {s.max():>10.4f}")
    print(f"    skew   : {s.skew():>10.4f}  {'-> asymétrique droite' if s.skew() > 1 else '-> symétrique' if abs(s.skew()) < 0.5 else '-> légèrement asymétrique'}")

# ══ 8. ANALYSE TEMPORELLE ════════════════════════════════════════
print("\n" + "═"*60)
print("Analyse temporelle de la colonne 'date'")
print("═"*60)

# Convertir la date
df["date"] = pd.to_datetime(df["date"], errors="coerce")
dates_valides = df["date"].dropna()

print(f"Dates valides    : {len(dates_valides):,}")
print(f"Dates invalides  : {df['date'].isnull().sum()}")
print(f"Date minimum     : {dates_valides.min()}")
print(f"Date maximum     : {dates_valides.max()}")

# Répartition par année
print("\n  Transactions par année :")
print(df["date"].dt.year.value_counts().sort_index())

# Répartition par mois (top 5)
print("\n  Top 5 mois par volume :")
print(df["date"].dt.to_period("M").value_counts().head(5))
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  EXPLICATION LIGNE PAR LIGNE — CONCEPTS CLÉS PARTIE 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LIGNE : df["date"] = pd.to_datetime(df["date"], errors="coerce")
  pd.to_datetime()  -> convertit une colonne en type datetime64
  df["date"]        -> colonne "date" du DataFrame (Series)
  errors="coerce"   -> si conversion impossible -> NaT (Not a Time)
  Sans errors="coerce", une date invalide lèverait une ValueError et
  ferait planter le programme.

LIGNE : df[col].astype("category")
  .astype()       -> convertit le type d'une Series
  "category"      -> type spécial pandas qui stocke les valeurs uniques
                    une seule fois et encode chaque ligne avec un entier
  Exemple :
    "France" "France" "Allemagne" "France"
    -> stocké comme [0, 0, 1, 0] avec mapping {0: "France", 1: "Allemagne"}
  Gain mémoire : massif sur les colonnes avec peu de valeurs uniques

LIGNE : df.memory_usage(deep=True).sum() / 1024**2
  .memory_usage(deep=True) -> Series : mémoire de chaque colonne en octets
                             deep=True -> calcule la vraie mémoire (strings)
  .sum()                   -> total en octets
  / 1024**2                -> convertir octets en Mo (1 Mo = 1024×1024 octets)

LIGNE : df.select_dtypes(include=[np.number])
  .select_dtypes()         -> filtre les colonnes selon leur type
  include=[np.number]      -> garder seulement les colonnes numériques
  Équivalent à : df[[col for col in df if df[col].dtype in [int, float]]]

LIGNE : df["date"].dt.year.value_counts().sort_index()
  .dt                 -> accesseur pour les opérations datetime
  .year               -> extrait l'année de chaque date
  .value_counts()     -> compte les occurrences de chaque valeur
  .sort_index()       -> trier par l'index (années dans l'ordre)

LIGNE : df["date"].dt.to_period("M").value_counts().head(5)
  .to_period("M")  -> convertit en période mensuelle (2022-01, 2022-02...)
  "M" = Monthly, "Q" = Quarterly, "Y" = Yearly, "D" = Daily
  .value_counts()  -> compte les transactions par mois
  .head(5)         -> les 5 mois les plus actifs

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  ANALYSE ET INTERPRÉTATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

RÉSULTATS ATTENDUS DE L'AUDIT :

1. TYPES INITIAUX (problèmes) :
   date         : object (devrait être datetime64)
   quantity     : float64 (devrait être Int64 — entier nullable)
   discount_pct : float64 (pourrait être float32)
   category, country, etc. : object (devraient être Categorical)

2. APRÈS CONVERSION :
   Gain mémoire attendu : ~40-60% (notamment grâce aux Categorical)

3. DISTRIBUTION unit_price :
   mean ≈ 180€, median ≈ 130€ (asymétrie droite -> produits premium tirent la moyenne)
   min  -> valeur négative (erreur) | max -> ~1300€ (vélo électrique)

4. DISTRIBUTION quantity :
   Majorité = 1-3 unités (particuliers)
   Quelques commandes en quantité élevée (B2B : 10, 12, 24 unités)

5. DISTRIBUTION discount_pct :
   ~60% des commandes sans remise (0.0)
   Remises entre 5% et 20% pour les achats en volume
   NaN signifie remise non renseignée (à imputer par 0.0 en nettoyage)

INSIGHTS BUSINESS :
  -> Le prix moyen est tiré vers le haut par l'électronique et l'ameublement
  -> Les remises sont cohérentes avec une politique "volume = remise"
  -> Les NaN sur discount_pct sont probablement des 0% non encodés

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PARTIE 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────

Ex 2.1 : Calculez la mémoire utilisée par le DataFrame AVANT et APRÈS
  la conversion des colonnes texte en Categorical.
  Affichez le gain en % et en MB.

Ex 2.2 : Utilisez df.describe(include="all") pour répondre :
  - Quel est le country le plus fréquent ?
  - Quel est le unit_price moyen ?
  - Combien de commandes ont une quantité non nulle ?

── NIVEAU INTERMÉDIAIRE ────────────────────────────────────────────

Ex 2.3 : Écrivez une fonction `detecter_anomalies_types(df)` qui :
  - Vérifie si la colonne "date" est bien de type datetime
  - Vérifie si unit_price ne contient que des floats
  - Vérifie si quantity ne contient que des entiers (ou NaN)
  - Retourne un rapport des problèmes trouvés

Ex 2.4 : Analysez la corrélation entre quantity et discount_pct.
  Les clients qui achètent plus ont-ils des remises plus élevées ?
  Utilisez .corr() et expliquez le résultat.

── NIVEAU AVANCÉ ────────────────────────────────────────────────────

Ex 2.5 : Implémentez une méthode `charger_chunked(taille_chunk=2000)`
  qui lit le fichier Excel par morceaux (chunks) et les concatène.
  Utilisez pd.read_excel(chunksize=2000) pour simuler la gestion
  de très gros fichiers. Mesurez le temps vs. chargement normal.

Ex 2.6 : Créez un profil HTML automatique avec pandas-profiling
  (ou ydata-profiling) :
  pip install ydata-profiling
  from ydata_profiling import ProfileReport
  profile = ProfileReport(df, title="EuroShop Commerce")
  profile.to_file("reports/profil_dataset.html")

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉS DÉTAILLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# ── Corrigé Ex 2.1 — Optimisation mémoire ────────────────────────
import pandas as pd
import numpy as np

df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")

# Mémoire AVANT
mem_avant = df.memory_usage(deep=True).sum() / 1024**2
print(f"Mémoire avant : {mem_avant:.2f} MB")

# Conversion
df_opt = df.copy()
for col in ["country", "region", "category", "payment_method",
            "customer_segment", "order_status"]:
    df_opt[col] = df_opt[col].astype("category")
df_opt["unit_price"] = df_opt["unit_price"].astype("float32")
df_opt["discount_pct"] = df_opt["discount_pct"].astype("float32")

# Mémoire APRÈS
mem_apres = df_opt.memory_usage(deep=True).sum() / 1024**2
gain = (mem_avant - mem_apres) / mem_avant * 100
print(f"Mémoire après : {mem_apres:.2f} MB")
print(f"Gain          : {gain:.1f}% ({mem_avant - mem_apres:.2f} MB économisés)")


# ── Corrigé Ex 2.3 — Détection d'anomalies de types ──────────────
def detecter_anomalies_types(df: pd.DataFrame) -> dict:
    """Vérifie les types des colonnes critiques."""
    problemes = {}

    # Vérification de la date
    if not pd.api.types.is_datetime64_any_dtype(df["date"]):
        problemes["date"] = {
            "type_actuel": str(df["date"].dtype),
            "type_attendu": "datetime64",
            "action": "pd.to_datetime(df['date'], errors='coerce')"
        }
        # Essai de conversion pour compter les erreurs
        dates_converties = pd.to_datetime(df["date"], errors="coerce")
        n_invalides = dates_converties.isnull().sum() - df["date"].isnull().sum()
        problemes["date"]["valeurs_invalides"] = int(n_invalides)

    # Vérification unit_price (doit être float, sans négatifs)
    if df["unit_price"].dtype not in ["float32", "float64"]:
        problemes["unit_price_type"] = {
            "type_actuel": str(df["unit_price"].dtype),
            "type_attendu": "float"
        }
    prix_negatifs = (df["unit_price"] < 0).sum()
    if prix_negatifs > 0:
        problemes["unit_price_negatifs"] = {
            "count": int(prix_negatifs),
            "action": "Correction nécessaire (Partie 3)"
        }

    # Vérification quantity (doit être entier ou NaN)
    qty_non_entier = df["quantity"].dropna()
    qty_non_entier = qty_non_entier[qty_non_entier != qty_non_entier.astype(int)]
    if len(qty_non_entier) > 0:
        problemes["quantity_non_entier"] = {
            "count": len(qty_non_entier),
            "exemples": qty_non_entier.head(3).tolist()
        }

    return problemes

import json
df_test = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")
problemes = detecter_anomalies_types(df_test)
print(json.dumps(problemes, indent=2, ensure_ascii=False))


# ── Corrigé Ex 2.4 — Corrélation quantity / discount_pct ─────────
df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")

# Corrélation de Pearson sur les valeurs non-NaN
# .dropna(subset=[...]) -> supprimer les lignes avec NaN dans ces colonnes
df_valid = df[["quantity", "discount_pct"]].dropna()
corr_pearson  = df_valid.corr().loc["quantity", "discount_pct"]
corr_spearman = df_valid.corr(method="spearman").loc["quantity", "discount_pct"]

print(f"Corrélation Pearson  (qty vs remise) : {corr_pearson:.4f}")
print(f"Corrélation Spearman (qty vs remise) : {corr_spearman:.4f}")

# Analyse par groupes de quantité
bins = [0, 1, 3, 5, 10, 24]
labels = ["1", "2-3", "4-5", "6-10", "11-24"]
df["qty_groupe"] = pd.cut(df["quantity"], bins=bins, labels=labels)
print("\nRemise moyenne par groupe de quantité :")
print(df.groupby("qty_groupe", observed=True)["discount_pct"].mean().round(3))
# Résultat attendu : remise plus élevée pour les grandes quantités
```

================================================================================
FIN PARTIE 2 — Prochaine étape : Partie 3 — Nettoyage des données
================================================================================

================================================================================
  [GRAPHIQUE] DataInsight Pro — Plateforme Professionnelle d'Analyse de Données
  PARTIE 3 — NETTOYAGE COMPLET DES DONNÉES (data_cleaning.py)
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Après l'audit de la Partie 2, vous avez identifié 6 problèmes :
  ① 253 valeurs NaN dans quantity
  ② 383 valeurs NaN dans discount_pct
  ③ ~10 prix unitaires négatifs (avoirs mal encodés)
  ④ ~5 quantités égales à zéro
  ⑤ ~8 dates en 2025 (hors plage 2022-2023)
  ⑥ Colonne "chiffre_affaires" absente (à créer)

La responsable qualité vous demande un pipeline de nettoyage reproductible,
documenté et traçable (chaque transformation doit être loggée).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION — src/data_cleaning.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# src/data_cleaning.py
"""
Module de nettoyage des données EuroShop Commerce.

Responsabilité UNIQUE : détecter et corriger les problèmes de qualité.
Travaille TOUJOURS sur une copie (jamais l'original).
Chaque transformation est documentée et tracée.

Pipeline de nettoyage :
  1. Suppression des doublons
  2. Correction des prix négatifs
  3. Traitement des quantités nulles
  4. Traitement des dates hors plage
  5. Imputation des valeurs manquantes
  6. Création de colonnes dérivées
  7. Normalisation des chaînes de caractères
"""

import pandas as pd
import numpy as np
import logging
from typing import Dict, Tuple, List
from pathlib import Path

logger = logging.getLogger("DataInsightPro")

# ── Constantes ────────────────────────────────────────────────────
DATE_MIN = pd.Timestamp("2022-01-01")
DATE_MAX = pd.Timestamp("2023-12-31")
PRIX_MIN_VALIDE = 0.01   # Un prix HT ne peut pas être ≤ 0
QTE_MIN_VALIDE  = 1      # Quantité minimale commandée


class DataCleaner:
    """
    Pipeline de nettoyage des données EuroShop Commerce.

    Philosophie :
      -> Toutes les transformations sur df_clean (copie)
      -> df_brut reste intact et inchangé
      -> Chaque étape produit un rapport
      -> On peut inspecter avant/après chaque transformation
    """

    def __init__(self):
        """Initialise le nettoyeur avec un journal des transformations."""
        # Journal des transformations : liste de dicts
        # Permet de tracer CE QUI a été fait, COMBIEN de lignes impactées, POURQUOI
        self.journal: List[Dict] = []
        self._df_clean: pd.DataFrame = None

    def _journaliser(self, etape: str, lignes_avant: int,
                     lignes_apres: int, details: str = "") -> None:
        """
        Ajoute une entrée dans le journal des transformations.

        Exemple d'entrée :
        {
          "etape": "Suppression prix négatifs",
          "lignes_avant": 12000,
          "lignes_apres": 11990,
          "lignes_impactées": 10,
          "details": "Prix négatifs -> prix absolu"
        }
        """
        entree = {
            "etape":            etape,
            "lignes_avant":     lignes_avant,
            "lignes_apres":     lignes_apres,
            "lignes_impactees": lignes_avant - lignes_apres
                                if lignes_apres <= lignes_avant
                                else 0,
            "lignes_modifiees": abs(lignes_avant - lignes_apres),
            "details":          details,
        }
        self.journal.append(entree)
        logger.info(
            f"  [{etape}] : {entree['lignes_modifiees']} lignes impactées. {details}"
        )

    # ─────────────────────────────────────────────────────────────
    # ÉTAPE 1 : SUPPRIMER LES DOUBLONS EXACTS
    # ─────────────────────────────────────────────────────────────
    def supprimer_doublons(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Supprime les lignes entièrement dupliquées.

        Logique :
          Si deux lignes ont EXACTEMENT les mêmes valeurs sur toutes les colonnes,
          c'est une erreur d'insertion en doublon.

        Paramètre : df — DataFrame à nettoyer
        Retourne  : DataFrame sans doublons exacts
        """
        avant = len(df)
        # .drop_duplicates() -> supprime les lignes dupliquées
        # keep="first" -> garder la première occurrence, supprimer les suivantes
        df = df.drop_duplicates(keep="first")
        apres = len(df)

        self._journaliser(
            "Suppression doublons exacts",
            avant, apres,
            f"{avant - apres} doublons supprimés"
        )
        return df

    # ─────────────────────────────────────────────────────────────
    # ÉTAPE 2 : CORRIGER LES PRIX NÉGATIFS
    # ─────────────────────────────────────────────────────────────
    def corriger_prix_negatifs(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Corrige les prix négatifs.

        Stratégie métier :
          Un prix négatif dans ce dataset représente un avoir (remboursement)
          mal encodé. Stratégie : prendre la valeur absolue.
          Alternative possible : marquer comme avoir dans une nouvelle colonne.

        Paramètre : df — DataFrame à nettoyer
        Retourne  : DataFrame avec prix corrigés
        """
        # Masque booléen : True là où le prix est négatif
        masque_negatifs = df["unit_price"] < 0

        # .sum() sur un masque booléen = nombre de True
        n_negatifs = masque_negatifs.sum()

        if n_negatifs > 0:
            # Journaliser les exemples avant correction
            exemples = df.loc[masque_negatifs, ["invoice_id", "unit_price"]].head(3)
            logger.debug(f"  Exemples de prix négatifs :\n{exemples}")

            # Corriger : valeur absolue (np.abs)
            # df.loc[masque, colonne] -> sélectionner et modifier les lignes du masque
            df.loc[masque_negatifs, "unit_price"] = np.abs(
                df.loc[masque_negatifs, "unit_price"]
            )

        self._journaliser(
            "Correction prix négatifs",
            len(df), len(df),   # Nombre de lignes inchangé (modification, pas suppression)
            f"{n_negatifs} prix négatifs -> valeur absolue"
        )
        return df

    # ─────────────────────────────────────────────────────────────
    # ÉTAPE 3 : TRAITER LES QUANTITÉS NULLES ET INVALIDES
    # ─────────────────────────────────────────────────────────────
    def traiter_quantites_invalides(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Traite les quantités nulles (0) et supprime les lignes correspondantes.

        Stratégie :
          - Quantité = 0 -> commande annulée avant traitement -> SUPPRIMER la ligne
          - Quantité < 0 -> erreur saisie -> SUPPRIMER
          - On garde les NaN pour l'imputation (étape suivante)

        Paramètre : df — DataFrame à nettoyer
        Retourne  : DataFrame sans quantités invalides
        """
        avant = len(df)

        # Masque : quantité <= 0 ET pas NaN
        # pd.notna() -> True si la valeur n'est pas NaN
        # Les NaN passent à l'étape d'imputation
        masque_invalides = (df["quantity"] <= 0) & pd.notna(df["quantity"])

        if masque_invalides.sum() > 0:
            logger.debug(f"  Quantités invalides :\n{df[masque_invalides][['invoice_id','quantity']].head()}")
            df = df[~masque_invalides]   # ~ = NOT -> garder les lignes SANS le masque

        self._journaliser(
            "Suppression quantités <= 0",
            avant, len(df),
            f"{masque_invalides.sum()} lignes avec quantité ≤ 0 supprimées"
        )
        return df

    # ─────────────────────────────────────────────────────────────
    # ÉTAPE 4 : TRAITER LES DATES HORS PLAGE
    # ─────────────────────────────────────────────────────────────
    def traiter_dates_invalides(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Supprime les lignes avec des dates hors de la plage 2022-2023.

        Stratégie :
          - Dates futures (2025) -> erreurs de saisie -> SUPPRIMER
          - Dates antérieures à 2022 -> hors périmètre -> SUPPRIMER
          - NaT (dates non parsables) -> SUPPRIMER (on ne peut pas les corriger)

        Paramètre : df — DataFrame avec colonne 'date' en datetime
        Retourne  : DataFrame avec dates valides uniquement
        """
        # Convertir en datetime si pas encore fait
        if not pd.api.types.is_datetime64_any_dtype(df["date"]):
            df["date"] = pd.to_datetime(df["date"], errors="coerce")

        avant = len(df)

        # Masque des dates valides :
        # - date non nulle (NaT exclu)
        # - dans la plage [DATE_MIN, DATE_MAX]
        masque_valides = (
            df["date"].notna() &         # Exclure les NaT
            (df["date"] >= DATE_MIN) &   # Après le 2022-01-01
            (df["date"] <= DATE_MAX)     # Avant le 2023-12-31
        )

        n_invalides = (~masque_valides).sum()
        df = df[masque_valides]   # Garder seulement les valides

        self._journaliser(
            "Suppression dates invalides",
            avant, len(df),
            f"{n_invalides} dates hors 2022-2023 supprimées"
        )
        return df

    # ─────────────────────────────────────────────────────────────
    # ÉTAPE 5 : IMPUTER LES VALEURS MANQUANTES
    # ─────────────────────────────────────────────────────────────
    def imputer_valeurs_manquantes(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Impute les valeurs manquantes selon des règles métier.

        Règles d'imputation :
          - discount_pct NaN -> 0.0
            Raisonnement : si la remise n'est pas renseignée, c'est qu'il n'y en a pas.
            Mécanisme MAR (remise non saisie quand = 0)

          - quantity NaN -> médiane par catégorie
            Raisonnement : la quantité dépend du type de produit.
            MAR : lié à des bugs systèmes sur certaines catégories.

        Paramètre : df — DataFrame à compléter
        Retourne  : DataFrame sans NaN dans les colonnes critiques
        """
        df = df.copy()
        avant_total = df.isnull().sum().sum()

        # ── Imputation discount_pct -> 0.0 ──────────────────────
        n_remise_nan = df["discount_pct"].isnull().sum()
        df["discount_pct"] = df["discount_pct"].fillna(0.0)
        # .fillna(valeur) -> remplace les NaN par la valeur donnée
        logger.info(f"  discount_pct : {n_remise_nan} NaN -> 0.0")

        # ── Imputation quantity -> médiane par catégorie ─────────
        n_qty_nan = df["quantity"].isnull().sum()

        # groupby("category") -> grouper par catégorie
        # transform(lambda x: x.fillna(x.median())) ->
        #   Pour chaque groupe, calculer la médiane et remplir les NaN
        #   transform conserve l'index original (contrairement à agg)
        df["quantity"] = df.groupby("category", observed=True)["quantity"].transform(
            lambda x: x.fillna(x.median())
        )

        # Cas résiduel : si une catégorie n'a QUE des NaN, fillna(1)
        n_restants = df["quantity"].isnull().sum()
        if n_restants > 0:
            df["quantity"] = df["quantity"].fillna(1)
            logger.warning(f"  {n_restants} quantity encore NaN -> imputés à 1")

        logger.info(f"  quantity : {n_qty_nan} NaN -> médiane par catégorie")

        apres_total = df.isnull().sum().sum()
        self._journaliser(
            "Imputation des NaN",
            int(avant_total),   # "lignes_avant" utilisé comme "NaN avant"
            int(apres_total),
            f"{avant_total - apres_total} NaN imputés"
        )
        return df

    # ─────────────────────────────────────────────────────────────
    # ÉTAPE 6 : CRÉER LES COLONNES DÉRIVÉES
    # ─────────────────────────────────────────────────────────────
    def creer_colonnes_derivees(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Crée les colonnes calculées nécessaires à l'analyse.

        Nouvelles colonnes :
          chiffre_affaires_ht : unit_price × quantity × (1 - discount_pct)
          annee               : année de la commande (int)
          mois                : mois de la commande (int)
          trimestre           : trimestre (Q1, Q2, Q3, Q4)
          semaine             : numéro de semaine ISO
          est_remise          : booléen (True si discount_pct > 0)
          tranche_prix        : catégorie de prix ('Entrée', 'Milieu', 'Premium')

        Paramètre : df — DataFrame nettoyé
        Retourne  : DataFrame enrichi
        """
        df = df.copy()
        logger.info("Création des colonnes dérivées...")

        # ── Chiffre d'affaires HT ──────────────────────────────
        # Formule : CA = quantité × prix_unitaire × (1 - remise%)
        # Exemple  : 3 unités × 100€ × (1 - 0.10) = 270€
        df["chiffre_affaires_ht"] = (
            df["quantity"].astype(float) *
            df["unit_price"] *
            (1 - df["discount_pct"])
        ).round(2)
        # .round(2) -> arrondir à 2 décimales (centimes)
        logger.info("  [OK] chiffre_affaires_ht créé")

        # ── Colonnes temporelles ───────────────────────────────
        # .dt -> accesseur pour les opérations sur les datetime
        df["annee"]      = df["date"].dt.year.astype("Int16")
        # .astype("Int16") -> entier nullable, économise de la mémoire

        df["mois"]       = df["date"].dt.month.astype("Int8")
        # Mois 1-12, Int8 suffit (plage -128 à 127)

        df["trimestre"]  = df["date"].dt.quarter.astype("Int8")
        # Quarter : 1, 2, 3 ou 4

        df["nom_mois"] = df["date"].dt.month_name(locale="fr_FR.utf8").astype("category")
        # month_name(locale) -> "janvier", "février"...
        # locale "fr_FR" -> nécessite la locale française installée
        # Fallback en cas d'absence de locale française :
        # df["nom_mois"] = df["date"].dt.month_name()  # Anglais par défaut

        df["semaine"]    = df["date"].dt.isocalendar().week.astype("Int8")
        # Numéro de semaine ISO (1-53)

        df["jour_semaine"] = df["date"].dt.dayofweek.astype("Int8")
        # 0=Lundi, 1=Mardi, ..., 6=Dimanche

        df["est_weekend"] = df["jour_semaine"].isin([5, 6])
        # .isin([5,6]) -> True si Samedi (5) ou Dimanche (6)
        logger.info("  [OK] colonnes temporelles créées")

        # ── Indicateur de remise ───────────────────────────────
        # Comparaison booléenne : True si une remise a été appliquée
        df["est_remise"] = df["discount_pct"] > 0

        # ── Tranche de prix (segmentation) ────────────────────
        # pd.cut() -> découpe une variable continue en catégories
        df["tranche_prix"] = pd.cut(
            df["unit_price"],
            bins=[0, 30, 100, 300, float("inf")],    # Bornes des intervalles
            labels=["Entrée de gamme", "Milieu", "Haut de gamme", "Premium"],
            # right=True (défaut) -> intervalles (borne_gauche, borne_droite]
        )
        logger.info("  [OK] tranche_prix créée")

        # ── Catégorie client composite ─────────────────────────
        # Combiner pays et segment pour une analyse plus fine
        df["segment_pays"] = (
            df["customer_segment"].astype(str) + "_" +
            df["country"].astype(str)
        ).astype("category")

        logger.info(f"[OK] {df.shape[1]} colonnes au total après enrichissement")
        return df

    # ─────────────────────────────────────────────────────────────
    # ÉTAPE 7 : NORMALISER LES CHAÎNES
    # ─────────────────────────────────────────────────────────────
    def normaliser_chaines(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Normalise les colonnes texte pour éviter les doublons de labels.

        Transformations :
          - Supprimer les espaces en début/fin (.strip())
          - Normaliser les majuscules (pas de changement ici — noms propres)
          - Nettoyer les caractères invisibles

        Paramètre : df — DataFrame à normaliser
        Retourne  : DataFrame avec chaînes normalisées
        """
        df = df.copy()
        cols_texte = ["country", "region", "category", "product_name",
                      "payment_method", "customer_segment", "order_status"]

        for col in cols_texte:
            if col in df.columns:
                # .str -> accesseur pour les opérations sur les strings
                # .strip() -> supprimer espaces de début et fin
                # Ne peut pas s'appliquer directement sur Categorical
                if hasattr(df[col], "cat"):
                    # Si Categorical -> convertir, nettoyer, reconvertir
                    df[col] = df[col].astype(str).str.strip().astype("category")
                else:
                    df[col] = df[col].str.strip()

        logger.info("[OK] Chaînes normalisées")
        return df

    # ─────────────────────────────────────────────────────────────
    # PIPELINE COMPLET
    # ─────────────────────────────────────────────────────────────
    def nettoyer(self, df_brut: pd.DataFrame) -> pd.DataFrame:
        """
        Exécute toutes les étapes de nettoyage dans l'ordre.

        C'est la méthode principale à appeler depuis main.py.
        Elle orchestre toutes les étapes et retourne le DataFrame propre.

        Paramètre : df_brut — DataFrame brut (JAMAIS modifié)
        Retourne  : DataFrame nettoyé et enrichi
        """
        logger.info("╔══════════════════════════════════════════╗")
        logger.info("║     PIPELINE DE NETTOYAGE — DÉMARRAGE    ║")
        logger.info("╚══════════════════════════════════════════╝")

        # Toujours travailler sur une COPIE
        df = df_brut.copy()
        n_initial = len(df)
        logger.info(f"Lignes initiales : {n_initial:,}")

        # Étape 0 : Conversion des types (doit venir en premier)
        df["date"] = pd.to_datetime(df["date"], errors="coerce")
        for col in ["country","region","category","payment_method",
                    "customer_segment","order_status"]:
            if col in df.columns:
                df[col] = df[col].astype("category")

        # Étapes de nettoyage dans l'ordre logique
        df = self.supprimer_doublons(df)           # 1. Doublons
        df = self.corriger_prix_negatifs(df)       # 2. Prix négatifs
        df = self.traiter_quantites_invalides(df)  # 3. Quantités 0
        df = self.traiter_dates_invalides(df)      # 4. Dates hors plage
        df = self.normaliser_chaines(df)           # 5. Normalisation
        df = self.imputer_valeurs_manquantes(df)   # 6. Imputation NaN
        df = self.creer_colonnes_derivees(df)      # 7. Colonnes calculées

        n_final = len(df)
        logger.info(f"\nRésultat nettoyage :")
        logger.info(f"  Lignes initiales   : {n_initial:,}")
        logger.info(f"  Lignes finales     : {n_final:,}")
        logger.info(f"  Lignes supprimées  : {n_initial - n_final:,} "
                    f"({(n_initial - n_final) / n_initial * 100:.2f}%)")
        logger.info(f"  NaN restants       : {df.isnull().sum().sum()}")

        self._df_clean = df
        return df

    # ─────────────────────────────────────────────────────────────
    # AFFICHAGE DU JOURNAL
    # ─────────────────────────────────────────────────────────────
    def afficher_journal(self) -> None:
        """Affiche le journal des transformations."""
        print(f"\n{'═'*70}")
        print(f"  JOURNAL DES TRANSFORMATIONS")
        print(f"{'═'*70}")
        print(f"  {'Étape':35s} {'Modifiées':>10s}  Détails")
        print(f"  {'─'*65}")
        for entree in self.journal:
            print(f"  {entree['etape']:35s} {entree['lignes_modifiees']:>10,}  {entree['details']}")
        print(f"{'═'*70}")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  EXPLICATION DES CONCEPTS CLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

MASQUES BOOLÉENS PANDAS :
  masque = df["unit_price"] < 0
  -> Crée une Series de True/False
  -> True = la ligne satisfait la condition
  df[masque]    -> sélectionner les lignes où masque = True
  df[~masque]   -> sélectionner les lignes où masque = False (inverse)
  df.loc[masque, "colonne"] = valeur -> modifier ces lignes

GROUPBY + TRANSFORM :
  df.groupby("category")["quantity"].transform(lambda x: x.fillna(x.median()))
  -> groupby("category")  : regrouper par catégorie
  -> ["quantity"]          : travailler sur la colonne quantity
  -> .transform()          : appliquer une fonction et CONSERVER l'index original
  -> lambda x: x.fillna(x.median()) : remplir les NaN par la médiane du GROUPE
  Différence avec .agg() : agg() réduit à une ligne par groupe,
                           transform() garde toutes les lignes (même index)

PD.CUT() :
  pd.cut(valeurs, bins=[0, 30, 100, 300, inf], labels=["A","B","C","D"])
  -> Découpe une variable continue en intervalles discrets
  -> Très utile pour créer des catégories (tranches de prix, âge, etc.)
  bins=[0,30,100,300,inf] -> intervalles : (0,30], (30,100], (100,300], (300,∞]
  labels=["A","B","C","D"] -> nom de chaque intervalle

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PARTIE 3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ex 3.1 : FACILE — Après nettoyage, comparez les statistiques de unit_price
  et quantity avant/après. La moyenne a-t-elle changé ?

Ex 3.2 : INTERMÉDIAIRE — Ajoutez une étape de nettoyage qui détecte
  les outliers (Z-score > 3) dans unit_price et les flagge dans une
  colonne "est_outlier_prix" sans les supprimer.

Ex 3.3 : AVANCÉ — Implémentez une méthode `rapport_qualite()` qui génère
  un fichier Markdown (reports/qualite_donnees.md) avec :
  - Tableau des NaN avant/après
  - Nombre de lignes supprimées
  - Justification de chaque décision de nettoyage

================================================================================
  [GRAPHIQUE] DataInsight Pro — PARTIE 4 — ANALYSE EXPLORATOIRE (EDA)
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Avec le dataset nettoyé (~11 977 lignes), vous pouvez maintenant répondre
aux questions du directeur commercial :
  ① "Quels sont nos produits et catégories les plus rentables ?"
  ② "Dans quels pays nos ventes sont-elles les meilleures ?"
  ③ "Comment les ventes évoluent-elles dans le temps ?"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION — src/analysis.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# src/analysis.py
"""
Module d'analyse des données EuroShop Commerce.

Responsabilité : calculer les KPIs et statistiques sur données nettoyées.
Chaque méthode retourne un DataFrame résultat prêt pour la visualisation.
"""

import pandas as pd
import numpy as np
import logging
from typing import Dict, Optional

logger = logging.getLogger("DataInsightPro")


class Analyste:
    """
    Calcule les métriques business sur le dataset EuroShop Commerce.

    Toutes les méthodes :
      - Prennent un DataFrame nettoyé en entrée
      - Retournent un DataFrame résultat
      - Ne modifient JAMAIS le DataFrame d'entrée
    """

    # ─────────────────────────────────────────────────────────────
    # KPIs GLOBAUX
    # ─────────────────────────────────────────────────────────────
    def kpis_globaux(self, df: pd.DataFrame) -> Dict:
        """
        Calcule les indicateurs clés de performance globaux.

        Retourne un dictionnaire des KPIs principaux.
        """
        logger.info("Calcul des KPIs globaux...")

        kpis = {
            # Volume
            "nb_transactions":     len(df),
            "nb_clients_uniques":  df["customer_id"].nunique(),
            "nb_produits_uniques": df["product_name"].nunique(),
            "nb_pays":             df["country"].nunique(),

            # Financier
            "ca_total_ht":         round(df["chiffre_affaires_ht"].sum(), 2),
            "ca_moyen_par_cmd":    round(df["chiffre_affaires_ht"].mean(), 2),
            "ca_median_par_cmd":   round(df["chiffre_affaires_ht"].median(), 2),
            "panier_moyen":        round(
                df.groupby("invoice_id")["chiffre_affaires_ht"].sum().mean(), 2
            ),

            # Remises
            "pct_cmds_avec_remise":round(df["est_remise"].mean() * 100, 1),
            "remise_moyenne":      round(df["discount_pct"].mean() * 100, 2),

            # Commandes
            "qte_totale":          int(df["quantity"].sum()),
            "qte_moyenne_cmd":     round(df["quantity"].mean(), 2),

            # Statuts
            "pct_livrees":         round((df["order_status"] == "Livré").mean() * 100, 1),
            "pct_annulees":        round((df["order_status"] == "Annulé").mean() * 100, 1),
            "pct_retournees":      round((df["order_status"] == "Retourné").mean() * 100, 1),

            # Période
            "date_debut":          str(df["date"].min().date()),
            "date_fin":            str(df["date"].max().date()),
        }

        logger.info(f"  CA total : {kpis['ca_total_ht']:,.2f} €")
        logger.info(f"  Clients  : {kpis['nb_clients_uniques']:,}")
        return kpis

    # ─────────────────────────────────────────────────────────────
    # ANALYSE PAR CATÉGORIE
    # ─────────────────────────────────────────────────────────────
    def analyse_par_categorie(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Analyse les performances par catégorie de produit.

        Retourne : DataFrame avec métriques par catégorie, trié par CA décroissant
        """
        logger.info("Analyse par catégorie...")

        # groupby -> regrouper par catégorie
        # agg -> appliquer plusieurs fonctions d'agrégation
        resultat = df.groupby("category", observed=True).agg(
            nb_transactions =("invoice_id",          "count"),
            ca_total        =("chiffre_affaires_ht", "sum"),
            ca_moyen        =("chiffre_affaires_ht", "mean"),
            nb_clients      =("customer_id",          "nunique"),
            qte_totale      =("quantity",             "sum"),
            prix_moyen      =("unit_price",           "mean"),
            remise_moyenne  =("discount_pct",         "mean"),
        ).round(2)

        # Trier par CA décroissant
        resultat = resultat.sort_values("ca_total", ascending=False)

        # Ajouter le rang et la part de marché
        resultat["rang"] = range(1, len(resultat) + 1)
        total_ca = resultat["ca_total"].sum()
        resultat["part_marche_pct"] = (resultat["ca_total"] / total_ca * 100).round(1)
        resultat["part_marche_cumul"] = resultat["part_marche_pct"].cumsum().round(1)

        # Réinitialiser l'index pour que "category" soit une colonne normale
        resultat = resultat.reset_index()

        logger.info(f"  Top 1 : {resultat.iloc[0]['category']} "
                    f"({resultat.iloc[0]['ca_total']:,.0f} €)")
        return resultat

    # ─────────────────────────────────────────────────────────────
    # ANALYSE PAR PAYS
    # ─────────────────────────────────────────────────────────────
    def analyse_par_pays(self, df: pd.DataFrame) -> pd.DataFrame:
        """Analyse les performances par pays."""
        logger.info("Analyse par pays...")

        resultat = df.groupby("country", observed=True).agg(
            nb_transactions =("invoice_id",          "count"),
            ca_total        =("chiffre_affaires_ht", "sum"),
            ca_moyen        =("chiffre_affaires_ht", "mean"),
            nb_clients      =("customer_id",          "nunique"),
            panier_moyen    =("chiffre_affaires_ht",
                               lambda x: df.loc[x.index].groupby("invoice_id")["chiffre_affaires_ht"].sum().mean()),
            prix_moyen      =("unit_price",           "mean"),
        ).round(2)

        resultat = resultat.sort_values("ca_total", ascending=False)
        total_ca = resultat["ca_total"].sum()
        resultat["part_marche_pct"]  = (resultat["ca_total"] / total_ca * 100).round(1)
        resultat["transactions_pct"] = (resultat["nb_transactions"] / resultat["nb_transactions"].sum() * 100).round(1)
        resultat["rang"] = range(1, len(resultat) + 1)
        return resultat.reset_index()

    # ─────────────────────────────────────────────────────────────
    # ANALYSE TEMPORELLE
    # ─────────────────────────────────────────────────────────────
    def analyse_temporelle(self, df: pd.DataFrame,
                            frequence: str = "ME") -> pd.DataFrame:
        """
        Analyse l'évolution du CA dans le temps.

        Paramètre :
            frequence : "ME" (mensuel), "QE" (trimestriel), "YE" (annuel), "W" (hebdo)

        Retourne : DataFrame avec CA par période
        """
        logger.info(f"Analyse temporelle (fréquence={frequence})...")

        # Définir date comme index pour le resample
        df_temp = df.set_index("date")

        # .resample() -> regrouper par période de temps
        # "ME" = Month End (fin de mois)
        # Équivalent à groupby sur l'année-mois
        resultat = df_temp.resample(frequence).agg(
            ca_total        =("chiffre_affaires_ht", "sum"),
            nb_transactions =("invoice_id",          "count"),
            nb_clients      =("customer_id",          "nunique"),
            ca_moyen        =("chiffre_affaires_ht", "mean"),
        ).round(2)

        # Variation mensuelle (mois sur mois)
        # .pct_change() -> ((valeur_actuelle - valeur_précédente) / valeur_précédente) × 100
        resultat["variation_ca_pct"] = (resultat["ca_total"].pct_change() * 100).round(1)

        # Moyenne mobile sur 3 périodes (lissage)
        # .rolling(3).mean() -> moyenne des 3 périodes précédentes
        resultat["ma3_ca"] = resultat["ca_total"].rolling(window=3, min_periods=1).mean().round(2)

        # CA cumulé (running total)
        resultat["ca_cumul"] = resultat["ca_total"].cumsum().round(2)

        return resultat.reset_index()

    # ─────────────────────────────────────────────────────────────
    # TOP PRODUITS
    # ─────────────────────────────────────────────────────────────
    def top_produits(self, df: pd.DataFrame, n: int = 10) -> pd.DataFrame:
        """
        Retourne les N meilleurs produits par CA.

        Paramètre :
            n : nombre de produits à retourner
        """
        resultat = df.groupby("product_name", observed=True).agg(
            ca_total        =("chiffre_affaires_ht", "sum"),
            nb_commandes    =("invoice_id",          "count"),
            qte_vendue      =("quantity",            "sum"),
            prix_moyen      =("unit_price",          "mean"),
            nb_clients      =("customer_id",         "nunique"),
        ).round(2)

        resultat = resultat.sort_values("ca_total", ascending=False).head(n)

        # Extraire la catégorie du produit
        mapping_categorie = df.set_index("product_name")["category"].to_dict()
        resultat["categorie"] = resultat.index.map(mapping_categorie)

        return resultat.reset_index()

    # ─────────────────────────────────────────────────────────────
    # ANALYSE CLIENTS (RFM basique)
    # ─────────────────────────────────────────────────────────────
    def analyse_rfm(self, df: pd.DataFrame,
                    date_reference: Optional[pd.Timestamp] = None) -> pd.DataFrame:
        """
        Calcule les métriques RFM (Recency, Frequency, Monetary) par client.

        RFM = méthode de segmentation client :
          R (Recency)  : depuis combien de jours le client a-t-il commandé ?
          F (Frequency): combien de commandes a-t-il passé ?
          M (Monetary) : quel est son CA total ?

        Plus R est faible (commande récente) -> client actif
        Plus F est élevé (nombreuses commandes) -> client fidèle
        Plus M est élevé -> client à forte valeur

        Paramètre :
            date_reference : date pivot pour calculer la récence
                             (par défaut : dernière date du dataset + 1 jour)

        Retourne : DataFrame avec R, F, M par customer_id
        """
        if date_reference is None:
            date_reference = df["date"].max() + pd.Timedelta(days=1)

        logger.info(f"Calcul RFM (date de référence : {date_reference.date()})...")

        # Calculer R, F, M par client
        rfm = df.groupby("customer_id").agg(
            derniere_commande =("date",                "max"),
            nb_commandes      =("invoice_id",          "count"),
            ca_total          =("chiffre_affaires_ht", "sum"),
        )

        # Recency : nombre de jours depuis la dernière commande
        # Timedelta -> différence temporelle
        # .dt.days -> convertir en nombre de jours entier
        rfm["recency_jours"] = (date_reference - rfm["derniere_commande"]).dt.days

        rfm = rfm.drop(columns="derniere_commande")
        rfm.columns = ["frequency", "monetary", "recency"]
        rfm["monetary"] = rfm["monetary"].round(2)

        # Scoring RFM (1-5, 5 = meilleur)
        # pd.qcut -> découpage en quantiles égaux
        rfm["R_score"] = pd.qcut(rfm["recency"],  q=5, labels=[5,4,3,2,1])  # Inversé : petit R = bon
        rfm["F_score"] = pd.qcut(rfm["frequency"].rank(method="first"), q=5, labels=[1,2,3,4,5])
        rfm["M_score"] = pd.qcut(rfm["monetary"].rank(method="first"),  q=5, labels=[1,2,3,4,5])

        # Score RFM composite
        rfm["rfm_score"] = (
            rfm["R_score"].astype(int) +
            rfm["F_score"].astype(int) +
            rfm["M_score"].astype(int)
        )

        # Segmentation client basée sur le score
        def segmenter_client(score):
            if score >= 13: return "Champions"
            elif score >= 10: return "Fidèles"
            elif score >= 7:  return "Potentiels"
            elif score >= 4:  return "À risque"
            else:             return "Perdus"

        rfm["segment_rfm"] = rfm["rfm_score"].apply(segmenter_client)
        rfm["segment_rfm"] = rfm["segment_rfm"].astype("category")

        logger.info(f"  RFM calculé pour {len(rfm):,} clients")
        logger.info(f"  Distribution segments :")
        for seg, count in rfm["segment_rfm"].value_counts().items():
            logger.info(f"    {seg}: {count:,} clients")

        return rfm.reset_index()

    # ─────────────────────────────────────────────────────────────
    # ANALYSE DE CORRÉLATIONS
    # ─────────────────────────────────────────────────────────────
    def matrice_correlations(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Calcule la matrice de corrélation entre les variables numériques.
        """
        cols_num = ["unit_price", "quantity", "discount_pct",
                    "chiffre_affaires_ht"]
        return df[cols_num].corr(method="pearson").round(3)
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  RÉSULTATS ATTENDUS ET INSIGHTS BUSINESS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

KPIs GLOBAUX (valeurs approximatives) :
  CA total HT       : ~2 400 000 €
  Nb. transactions  : ~11 977
  Clients uniques   : ~2 447
  Panier moyen      : ~200 €
  Tx. livraison     : 72%
  Tx. annulation    : 8%
  Commandes remisées: ~40%

TOP CATÉGORIES PAR CA :
  1. Électronique      : ~500 000 € (21%)  <- Produits chers, volumes modérés
  2. Sports & Loisirs  : ~380 000 € (16%)  <- Vélos électriques (1 299€)
  3. Maison & Jardin   : ~330 000 € (14%)  <- Robots aspirateurs
  4. Vêtements         : ~290 000 € (12%)
  5. Alimentation      : ~240 000 € (10%)

TOP PAYS PAR CA :
  1. France      : ~28%  <- Marché domestique dominant
  2. Allemagne   : ~18%  <- 2ème marché, fort B2B
  3. Espagne     : ~12%
  4. Italie      : ~10%
  5. Royaume-Uni : ~10%

INSIGHT RFM :
  Champions (score ≥ 13) : ~8% des clients -> générent ~35% du CA
  Perdus (score < 4)     : ~15% des clients -> CA négligeable
  -> Concentration : 20% des clients = 60% du CA (loi de Pareto approximative)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PARTIE 4
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ex 4.1 : FACILE — Calculez le CA moyen par client et par pays.
  Quel pays a le panier le plus élevé par client ?

Ex 4.2 : INTERMÉDIAIRE — Créez une fonction `analyse_par_segment(df)` qui
  compare les métriques (CA, fréquence, panier) entre les segments
  Particulier, PME, Grande entreprise.

Ex 4.3 : INTERMÉDIAIRE — Calculez la croissance Year-over-Year du CA :
  CA 2023 vs CA 2022 en % pour chaque catégorie.
  Quelle catégorie a le plus crû ?

Ex 4.4 : AVANCÉ — Implémentez l'analyse de la courbe de Lorenz sur le CA :
  - Trier les clients par CA croissant
  - Calculer le % cumulé de clients et le % cumulé de CA
  - Calculer le coefficient de Gini (concentration des revenus)

Ex 4.5 : AVANCÉ — Créez une analyse de cohorte mensuelle :
  - Pour chaque cohorte (mois d'acquisition du client)
  - Calculez le CA généré chaque mois suivant
  - Affichez une heatmap de rétention

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CORRIGÉS PARTIES 3 & 4
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# ── Corrigé Ex 3.2 — Détection outliers prix (Z-score) ───────────
import pandas as pd
import numpy as np
from scipy import stats

df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")

# Z-score : (valeur - moyenne) / ecart_type
# |z| > 3 -> plus de 3 écarts-types de la moyenne = outlier
z_scores = np.abs(stats.zscore(df["unit_price"].dropna()))

# Créer la colonne indicateur (False par défaut)
df["est_outlier_prix"] = False

# Réassigner les z-scores aux index valides
idx_valides = df["unit_price"].dropna().index
df.loc[idx_valides, "est_outlier_prix"] = z_scores > 3

n_outliers = df["est_outlier_prix"].sum()
print(f"Outliers prix (|Z|>3) : {n_outliers}")
print(df[df["est_outlier_prix"]][["invoice_id","product_name","unit_price"]].head(10))


# ── Corrigé Ex 4.1 — CA moyen par client et par pays ─────────────
# Calculer le CA total par client
ca_par_client = df.groupby("customer_id")["chiffre_affaires_ht"].sum()

# CA moyen par client ET par pays
resultat = df.groupby(["country", "customer_id"])["chiffre_affaires_ht"].sum()
ca_moyen_par_pays = resultat.groupby("country").mean().round(2)
ca_moyen_par_pays.name = "ca_moyen_par_client"
print("\nCA moyen par client, par pays :")
print(ca_moyen_par_pays.sort_values(ascending=False))


# ── Corrigé Ex 4.3 — Croissance YoY par catégorie ─────────────────
df["annee"] = pd.to_datetime(df["date"], errors="coerce").dt.year
ca_annuel = df.groupby(["annee", "category"])["chiffre_affaires_ht"].sum().unstack()
# unstack() -> pivote "annee" en colonnes -> DataFrame avec colonnes 2022, 2023

croissance = ((ca_annuel.loc[2023] - ca_annuel.loc[2022]) / ca_annuel.loc[2022] * 100).round(1)
croissance.name = "Croissance YoY (%)"
print("\nCroissance CA 2022->2023 par catégorie :")
print(croissance.sort_values(ascending=False))


# ── Corrigé Ex 4.4 — Courbe de Lorenz et Gini ────────────────────
ca_clients = df.groupby("customer_id")["chiffre_affaires_ht"].sum().sort_values()
n = len(ca_clients)
total = ca_clients.sum()

# % cumulé de clients et % cumulé de CA
x = np.arange(1, n+1) / n                     # % de clients (0->1)
y = ca_clients.cumsum() / total                 # % de CA cumulé (0->1)

# Coefficient de Gini = 2 × aire entre droite égalité et courbe de Lorenz
# = 1 - 2 × aire sous la courbe de Lorenz
gini = 1 - 2 * np.trapz(y, x)
print(f"\nCoefficient de Gini : {gini:.4f}")
print(f"Interprétation : {gini:.1%} de concentration du CA")
print(f"0 = égalité parfaite, 1 = un seul client génère tout le CA")

import matplotlib.pyplot as plt
plt.figure(figsize=(7,5))
plt.plot(x, y, 'b-', label=f"Lorenz (Gini={gini:.3f})")
plt.plot([0,1],[0,1],'r--', label="Égalité parfaite")
plt.fill_between(x, y, x, alpha=0.2, color='red', label="Zone d'inégalité")
plt.xlabel("% de clients (cumulé)")
plt.ylabel("% du CA (cumulé)")
plt.title("Courbe de Lorenz — Concentration du CA par client")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("reports/lorenz_curve.png", dpi=150)
plt.show()
```

================================================================================
FIN PARTIES 3 & 4 — Prochaine étape : Partie 5 — Visualisation
================================================================================

================================================================================
  [GRAPHIQUE] DataInsight Pro — PARTIE 5 — VISUALISATION PROFESSIONNELLE
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION — src/visualization.py
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# src/visualization.py
"""
Module de visualisation — DataInsight Pro EuroShop Commerce.

Responsabilité : créer et sauvegarder tous les graphiques de l'analyse.
Reçoit des DataFrames pré-calculés par analysis.py.
Ne fait AUCUN calcul — seulement de la représentation visuelle.

Convention de nommage des fichiers :
  fig_XX_description.png
  Où XX = numéro de la figure (ordre de rapport)
"""

import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import seaborn as sns
import pandas as pd
import numpy as np
import logging
from pathlib import Path
from typing import Optional, Dict

logger = logging.getLogger("DataInsightPro")

# ── Configuration globale du style ──────────────────────────────
# Appliquer une fois au démarrage du module
plt.rcParams.update({
    "figure.dpi":         150,          # Résolution des figures
    "figure.facecolor":   "#FAFAFA",    # Fond légèrement gris
    "axes.facecolor":     "#F5F5F5",    # Fond des axes
    "axes.grid":          True,         # Grille visible
    "grid.alpha":         0.4,          # Transparence de la grille
    "grid.color":         "#CCCCCC",    # Couleur grille
    "font.family":        "DejaVu Sans",
    "font.size":          10,
    "axes.titlesize":     13,
    "axes.labelsize":     11,
    "axes.spines.top":    False,         # Supprimer les bordures haut/droite
    "axes.spines.right":  False,
    "axes.prop_cycle":    plt.cycler("color", [
        "#2196F3", "#FF5722", "#4CAF50", "#9C27B0",
        "#FF9800", "#00BCD4", "#E91E63", "#607D8B"
    ]),
})

# Palette EuroShop Commerce
COULEUR_PRINCIPALE = "#2196F3"    # Bleu
COULEUR_SECONDAIRE = "#FF5722"    # Orange
COULEUR_SUCCES     = "#4CAF50"    # Vert
COULEUR_DANGER     = "#F44336"    # Rouge
PALETTE_PAYS = sns.color_palette("tab10", n_colors=10)


class Visualiseur:
    """
    Crée tous les graphiques du rapport DataInsight Pro.

    Chaque méthode :
      - Prend les DataFrames pré-calculés
      - Crée une figure matplotlib
      - Sauvegarde en PNG dans le dossier outputs/
      - Affiche si show=True
    """

    def __init__(self, dossier_output: str = "reports"):
        """
        Initialise le visualiseur.

        Paramètre :
            dossier_output : où sauvegarder les graphiques
        """
        self.dossier = Path(dossier_output)
        self.dossier.mkdir(parents=True, exist_ok=True)
        logger.info(f"Visualiseur initialisé -> {self.dossier}")

    def _sauvegarder(self, fig: plt.Figure, nom: str, show: bool = True) -> None:
        """Sauvegarde une figure et l'affiche optionnellement."""
        chemin = self.dossier / f"{nom}.png"
        fig.savefig(chemin, dpi=150, bbox_inches="tight",
                    facecolor=fig.get_facecolor())
        logger.info(f"  [SAUVEGARDE] Sauvegardé : {chemin.name}")
        if show:
            plt.show()
        plt.close(fig)   # Libérer la mémoire

    # ─────────────────────────────────────────────────────────────
    # FIGURE 1 : DASHBOARD KPIs GLOBAUX
    # ─────────────────────────────────────────────────────────────
    def fig_kpis_globaux(self, kpis: Dict, show: bool = True) -> None:
        """
        Crée un dashboard de KPIs avec des "metric cards".
        """
        logger.info("Création fig_01_kpis_globaux...")

        # Figure avec fond blanc
        fig = plt.figure(figsize=(16, 4), facecolor="#1F4E79")

        # Définir les KPIs à afficher
        metriques = [
            ("CA Total HT", f"{kpis['ca_total_ht']:,.0f} €", "[ARGENT]"),
            ("Transactions", f"{kpis['nb_transactions']:,}", "[PACKAGE]"),
            ("Clients Uniques", f"{kpis['nb_clients_uniques']:,}", "[UTILISATEUR]"),
            ("Panier Moyen", f"{kpis['panier_moyen']:,.0f} €", "[SHOPPING_TROLLEY]"),
            ("Tx. Livraison", f"{kpis['pct_livrees']:.1f}%", "[OK]"),
            ("Tx. Annulation", f"{kpis['pct_annulees']:.1f}%", "[X]"),
        ]

        n_metrics = len(metriques)
        # Créer les sous-figures en grille (1 ligne × n_metrics colonnes)
        gs = fig.add_gridspec(1, n_metrics, wspace=0.05)

        for i, (label, valeur, emoji) in enumerate(metriques):
            ax = fig.add_subplot(gs[0, i])
            ax.set_facecolor("#2E6DA4")
            ax.set_xlim(0, 1)
            ax.set_ylim(0, 1)
            ax.axis("off")   # Pas d'axes — juste du texte

            # Emoji en haut
            ax.text(0.5, 0.75, emoji, ha="center", va="center",
                    fontsize=24, transform=ax.transAxes)
            # Valeur au centre
            ax.text(0.5, 0.50, valeur, ha="center", va="center",
                    fontsize=16, fontweight="bold", color="white",
                    transform=ax.transAxes)
            # Label en bas
            ax.text(0.5, 0.25, label, ha="center", va="center",
                    fontsize=9, color="#B0C4DE",
                    transform=ax.transAxes)

        fig.suptitle("[GRAPHIQUE] DataInsight Pro — EuroShop Commerce 2022-2023",
                     fontsize=14, color="white", y=1.02)
        self._sauvegarder(fig, "fig_01_kpis_globaux", show)

    # ─────────────────────────────────────────────────────────────
    # FIGURE 2 : CA PAR CATÉGORIE (BARRES HORIZONTALES)
    # ─────────────────────────────────────────────────────────────
    def fig_ca_par_categorie(self, df_cat: pd.DataFrame,
                              show: bool = True) -> None:
        """
        Graphique à barres horizontales du CA par catégorie.
        Inclut les pourcentages de part de marché.
        """
        logger.info("Création fig_02_ca_par_categorie...")

        fig, axes = plt.subplots(1, 2, figsize=(16, 6),
                                  gridspec_kw={"width_ratios": [2, 1]})
        fig.suptitle("Chiffre d'Affaires par Catégorie de Produit",
                     fontsize=14, fontweight="bold")

        # ── Graphique principal : barres horizontales ────────────
        ax = axes[0]

        # Trier pour affichage croissant (dernière barre = plus longue = en haut)
        df_plot = df_cat.sort_values("ca_total", ascending=True)

        # Palette dégradée : du gris clair au bleu foncé
        n = len(df_plot)
        couleurs = [plt.cm.Blues(0.3 + 0.7 * i / (n-1)) for i in range(n)]

        barres = ax.barh(
            df_plot["category"],      # Labels sur l'axe Y
            df_plot["ca_total"],      # Valeurs sur l'axe X
            color=couleurs,
            edgecolor="white",
            linewidth=0.5,
            height=0.7
        )

        # Ajouter les valeurs en fin de barre
        for barre, valeur, pct in zip(barres, df_plot["ca_total"],
                                       df_plot["part_marche_pct"]):
            largeur = barre.get_width()
            ax.text(
                largeur + 2000,           # Légèrement à droite de la barre
                barre.get_y() + barre.get_height() / 2,
                f"{valeur:,.0f} € ({pct}%)",
                va="center", ha="left", fontsize=9
            )

        # Formatter l'axe X en euros
        ax.xaxis.set_major_formatter(
            mticker.FuncFormatter(lambda x, _: f"{x/1000:.0f}K €")
        )
        ax.set_xlabel("Chiffre d'Affaires HT")
        ax.set_title("CA par catégorie")
        ax.set_xlim(0, df_plot["ca_total"].max() * 1.3)

        # ── Graphique secondaire : camembert parts de marché ─────
        ax2 = axes[1]
        # Trier par CA décroissant pour le camembert
        df_pie = df_cat.sort_values("ca_total", ascending=False)

        wedges, texts, autotexts = ax2.pie(
            df_pie["ca_total"],
            labels=None,             # Pas de labels dans le camembert (trop chargé)
            autopct="%1.1f%%",       # Afficher le pourcentage
            startangle=90,           # Commencer en haut
            colors=[plt.cm.Blues(0.3 + 0.7 * i / (n-1)) for i in range(n-1, -1, -1)],
            pctdistance=0.8          # Distance du % par rapport au centre
        )

        # Ajuster la taille des textes
        for autotext in autotexts:
            autotext.set_fontsize(8)

        ax2.legend(
            wedges, df_pie["category"],
            title="Catégories",
            loc="lower center",
            bbox_to_anchor=(0.5, -0.3),
            fontsize=8,
            ncol=2
        )
        ax2.set_title("Parts de marché")

        plt.tight_layout()
        self._sauvegarder(fig, "fig_02_ca_par_categorie", show)

    # ─────────────────────────────────────────────────────────────
    # FIGURE 3 : ÉVOLUTION TEMPORELLE
    # ─────────────────────────────────────────────────────────────
    def fig_evolution_temporelle(self, df_temps: pd.DataFrame,
                                  show: bool = True) -> None:
        """
        Graphique de l'évolution mensuelle du CA avec moyenne mobile.
        """
        logger.info("Création fig_03_evolution_temporelle...")

        fig, axes = plt.subplots(2, 1, figsize=(14, 9), sharex=True)
        fig.suptitle("Évolution Temporelle des Ventes — 2022-2023",
                     fontsize=14, fontweight="bold")

        dates = df_temps["date"]

        # ── Panneau supérieur : CA mensuel + MA3 ─────────────────
        ax1 = axes[0]

        # Barres pour le CA mensuel
        ax1.bar(dates, df_temps["ca_total"],
                color=COULEUR_PRINCIPALE, alpha=0.6,
                label="CA mensuel", width=20)

        # Ligne pour la moyenne mobile 3 mois
        ax1.plot(dates, df_temps["ma3_ca"],
                 color=COULEUR_SECONDAIRE, linewidth=2.5,
                 marker="o", markersize=4,
                 label="MA 3 mois", zorder=5)

        # Zone colorée entre le CA et la MA3
        ax1.fill_between(dates, df_temps["ca_total"], df_temps["ma3_ca"],
                         alpha=0.15, color=COULEUR_SECONDAIRE)

        ax1.yaxis.set_major_formatter(
            mticker.FuncFormatter(lambda x, _: f"{x/1000:.0f}K€")
        )
        ax1.set_ylabel("Chiffre d'Affaires HT")
        ax1.legend(loc="upper left")
        ax1.set_title("CA mensuel et moyenne mobile (3 mois)")

        # ── Panneau inférieur : transactions et variation ─────────
        ax2 = axes[1]

        # Variation mensuelle sous forme de barres colorées
        couleurs_var = [
            COULEUR_SUCCES if v >= 0 else COULEUR_DANGER
            for v in df_temps["variation_ca_pct"].fillna(0)
        ]

        ax2.bar(dates, df_temps["variation_ca_pct"].fillna(0),
                color=couleurs_var, alpha=0.8,
                label="Variation mensuelle (%)", width=20)

        # Ligne à 0
        ax2.axhline(0, color="black", linewidth=0.8, linestyle="-")

        ax2.yaxis.set_major_formatter(
            mticker.FuncFormatter(lambda x, _: f"{x:+.0f}%")
        )
        ax2.set_ylabel("Variation MoM (%)")
        ax2.set_xlabel("Date")
        ax2.set_title("Variation mensuelle du CA (MoM)")
        ax2.legend()

        # Formater l'axe X en dates lisibles
        fig.autofmt_xdate(rotation=45)
        plt.tight_layout()
        self._sauvegarder(fig, "fig_03_evolution_temporelle", show)

    # ─────────────────────────────────────────────────────────────
    # FIGURE 4 : HEATMAP PAYS × CATÉGORIE
    # ─────────────────────────────────────────────────────────────
    def fig_heatmap_pays_categorie(self, df: pd.DataFrame,
                                    show: bool = True) -> None:
        """
        Heatmap croisée : pays en lignes, catégories en colonnes, CA en couleur.
        Révèle quels produits se vendent le mieux dans quel pays.
        """
        logger.info("Création fig_04_heatmap_pays_categorie...")

        # Créer le pivot table
        # pivot_table : agrège les données selon 2 dimensions
        pivot = df.pivot_table(
            values="chiffre_affaires_ht",
            index="country",
            columns="category",
            aggfunc="sum",
            observed=True,
            fill_value=0
        )

        # Normaliser par pays (ligne) pour comparer les préférences
        # (pas le volume absolu, qui varie selon la taille du pays)
        pivot_norm = pivot.div(pivot.sum(axis=1), axis=0) * 100
        # .div(pivot.sum(axis=1), axis=0) -> diviser chaque ligne par son total
        # × 100 -> convertir en %

        fig, ax = plt.subplots(figsize=(14, 6))

        sns.heatmap(
            pivot_norm,
            annot=True,            # Afficher les valeurs dans les cellules
            fmt=".1f",             # Format : 1 décimale
            cmap="YlOrRd",         # Palette : jaune -> orange -> rouge
            linewidths=0.5,        # Séparateurs entre cellules
            linecolor="white",
            cbar_kws={"label": "% du CA par pays"},
            ax=ax
        )

        ax.set_title(
            "Heatmap des Ventes : Part de chaque catégorie par pays (%)",
            fontsize=13, fontweight="bold"
        )
        ax.set_xlabel("Catégorie de produit")
        ax.set_ylabel("Pays")
        ax.set_xticklabels(ax.get_xticklabels(), rotation=30, ha="right")

        plt.tight_layout()
        self._sauvegarder(fig, "fig_04_heatmap_pays_categorie", show)

    # ─────────────────────────────────────────────────────────────
    # FIGURE 5 : ANALYSE RFM
    # ─────────────────────────────────────────────────────────────
    def fig_rfm_segments(self, df_rfm: pd.DataFrame,
                          show: bool = True) -> None:
        """
        Visualise la segmentation RFM :
        - Scatter Recency vs Monetary (coloré par segment)
        - Barres du nombre de clients par segment
        """
        logger.info("Création fig_05_rfm_segments...")

        fig, axes = plt.subplots(1, 2, figsize=(16, 6))
        fig.suptitle("Analyse RFM — Segmentation Clients",
                     fontsize=14, fontweight="bold")

        # Palette par segment
        segments_uniques = df_rfm["segment_rfm"].unique()
        palette = {seg: PALETTE_PAYS[i]
                   for i, seg in enumerate(segments_uniques)}

        # ── Scatter : Recency vs Monetary ────────────────────────
        ax1 = axes[0]

        for segment, groupe in df_rfm.groupby("segment_rfm", observed=True):
            ax1.scatter(
                groupe["recency"],    # X : jours depuis la dernière commande
                groupe["monetary"],   # Y : CA total du client
                label=segment,
                color=palette[segment],
                alpha=0.6,
                s=30,                 # Taille des points
                edgecolors="white",
                linewidths=0.3
            )

        ax1.set_xlabel("Récence (jours depuis dernière commande)")
        ax1.set_ylabel("CA Total Client (€)")
        ax1.set_title("Récence vs Valeur Monétaire par segment")
        ax1.legend(title="Segment", fontsize=9)
        ax1.yaxis.set_major_formatter(
            mticker.FuncFormatter(lambda x, _: f"{x:,.0f}€")
        )

        # ── Barres : nb clients par segment ──────────────────────
        ax2 = axes[1]

        counts = df_rfm["segment_rfm"].value_counts()
        # Ordonner les segments du meilleur au moins bon
        ordre = ["Champions", "Fidèles", "Potentiels", "À risque", "Perdus"]
        counts = counts.reindex([o for o in ordre if o in counts.index])

        barres = ax2.bar(
            counts.index,
            counts.values,
            color=[palette.get(s, "#999") for s in counts.index],
            edgecolor="white",
            linewidth=0.5
        )

        # Ajouter nb et % sur chaque barre
        total = counts.sum()
        for barre, (seg, n) in zip(barres, counts.items()):
            ax2.text(
                barre.get_x() + barre.get_width() / 2,
                barre.get_height() + 5,
                f"{n:,}\n({n/total*100:.1f}%)",
                ha="center", va="bottom", fontsize=9
            )

        ax2.set_xlabel("Segment RFM")
        ax2.set_ylabel("Nombre de Clients")
        ax2.set_title("Distribution des segments clients")
        ax2.tick_params(axis="x", rotation=15)

        plt.tight_layout()
        self._sauvegarder(fig, "fig_05_rfm_segments", show)

    # ─────────────────────────────────────────────────────────────
    # FIGURE 6 : TOP PRODUITS
    # ─────────────────────────────────────────────────────────────
    def fig_top_produits(self, df_produits: pd.DataFrame,
                          show: bool = True) -> None:
        """Barres horizontales des 10 produits avec le plus grand CA."""
        logger.info("Création fig_06_top_produits...")

        fig, ax = plt.subplots(figsize=(12, 7))

        df_plot = df_produits.sort_values("ca_total", ascending=True)

        # Couleur selon la catégorie
        categories = df_plot["categorie"].unique()
        palette_cat = {cat: plt.cm.tab10(i/len(categories))
                       for i, cat in enumerate(categories)}
        couleurs = [palette_cat.get(cat, "#999") for cat in df_plot["categorie"]]

        barres = ax.barh(df_plot["product_name"], df_plot["ca_total"],
                         color=couleurs, edgecolor="white", height=0.7)

        for barre, (_, row) in zip(barres, df_plot.iterrows()):
            ax.text(
                barre.get_width() + 500,
                barre.get_y() + barre.get_height() / 2,
                f"{row['ca_total']:,.0f} € | {int(row['qte_vendue'])} unités",
                va="center", ha="left", fontsize=8
            )

        # Légende des catégories
        from matplotlib.patches import Patch
        legende = [Patch(color=c, label=cat)
                   for cat, c in palette_cat.items()]
        ax.legend(handles=legende, title="Catégorie",
                  loc="lower right", fontsize=8)

        ax.xaxis.set_major_formatter(
            mticker.FuncFormatter(lambda x, _: f"{x/1000:.0f}K€")
        )
        ax.set_xlabel("Chiffre d'Affaires HT")
        ax.set_title("Top 10 Produits par Chiffre d'Affaires")
        ax.set_xlim(0, df_plot["ca_total"].max() * 1.35)

        plt.tight_layout()
        self._sauvegarder(fig, "fig_06_top_produits", show)

    # ─────────────────────────────────────────────────────────────
    # FIGURE 7 : DISTRIBUTION DES PRIX ET QUANTITÉS
    # ─────────────────────────────────────────────────────────────
    def fig_distributions(self, df: pd.DataFrame, show: bool = True) -> None:
        """Histogrammes et boxplots des variables continues."""
        logger.info("Création fig_07_distributions...")

        fig, axes = plt.subplots(2, 3, figsize=(16, 9))
        fig.suptitle("Distributions des Variables Clés", fontsize=14,
                     fontweight="bold")

        # ── Ligne 1 : Histogrammes ────────────────────────────────
        for i, (col, titre) in enumerate([
            ("unit_price",          "Prix Unitaire (€)"),
            ("quantity",            "Quantité commandée"),
            ("chiffre_affaires_ht", "CA par commande (€)")
        ]):
            ax = axes[0][i]
            data = df[col].dropna()

            ax.hist(data, bins=40, color=COULEUR_PRINCIPALE,
                    edgecolor="white", alpha=0.8, density=True)
            data.plot.kde(ax=ax, color=COULEUR_SECONDAIRE,
                          linewidth=2, label="KDE")

            # Lignes verticales pour moyenne et médiane
            ax.axvline(data.mean(), color="red", linestyle="--",
                       linewidth=1.5, label=f"μ={data.mean():.1f}")
            ax.axvline(data.median(), color="green", linestyle="--",
                       linewidth=1.5, label=f"Med={data.median():.1f}")

            ax.set_title(titre)
            ax.legend(fontsize=8)
            ax.set_xlabel("Valeur")
            ax.set_ylabel("Densité")

        # ── Ligne 2 : Boxplots par catégorie ────────────────────
        for i, (col, titre) in enumerate([
            ("unit_price",          "Prix par catégorie"),
            ("quantity",            "Quantité par catégorie"),
            ("chiffre_affaires_ht", "CA par catégorie")
        ]):
            ax = axes[1][i]
            sns.boxplot(
                data=df,
                x="category",
                y=col,
                ax=ax,
                palette="Blues",
                width=0.6,
                flierprops={"marker": ".", "markersize": 3, "alpha": 0.4},
                order=df.groupby("category", observed=True)[col]
                        .median().sort_values(ascending=False).index
            )
            ax.set_xticklabels(ax.get_xticklabels(), rotation=30,
                                ha="right", fontsize=7)
            ax.set_title(titre)
            ax.set_xlabel("")
            if col in ["unit_price", "chiffre_affaires_ht"]:
                ax.yaxis.set_major_formatter(
                    mticker.FuncFormatter(lambda x, _: f"{x:,.0f}€")
                )

        plt.tight_layout()
        self._sauvegarder(fig, "fig_07_distributions", show)

    # ─────────────────────────────────────────────────────────────
    # FIGURE 8 : PAIEMENTS ET SEGMENTS
    # ─────────────────────────────────────────────────────────────
    def fig_paiements_segments(self, df: pd.DataFrame,
                                show: bool = True) -> None:
        """
        Double visualisation : modes de paiement et segments clients.
        """
        logger.info("Création fig_08_paiements_segments...")

        fig, axes = plt.subplots(1, 2, figsize=(14, 6))
        fig.suptitle("Modes de Paiement et Segments Clients",
                     fontsize=14, fontweight="bold")

        # ── Paiements : barres groupées par année ─────────────────
        ax1 = axes[0]
        pivot_paiements = df.groupby(
            ["payment_method", "annee"], observed=True
        )["chiffre_affaires_ht"].sum().unstack(level="annee").fillna(0)

        pivot_paiements.plot(
            kind="bar", ax=ax1,
            color=[COULEUR_PRINCIPALE, COULEUR_SECONDAIRE],
            edgecolor="white", width=0.7, alpha=0.85
        )
        ax1.set_xticklabels(ax1.get_xticklabels(), rotation=25, ha="right")
        ax1.set_title("CA par mode de paiement et année")
        ax1.set_xlabel("")
        ax1.set_ylabel("CA HT (€)")
        ax1.yaxis.set_major_formatter(
            mticker.FuncFormatter(lambda x, _: f"{x/1000:.0f}K€")
        )
        ax1.legend(title="Année")

        # ── Segments : treemap / violinplot ───────────────────────
        ax2 = axes[1]
        sns.violinplot(
            data=df,
            x="customer_segment",
            y="chiffre_affaires_ht",
            ax=ax2,
            palette="Set2",
            inner="quartile",    # Afficher les quartiles à l'intérieur
            cut=0                # Ne pas dépasser les extrêmes des données
        )
        ax2.set_title("Distribution du CA par segment client")
        ax2.set_xlabel("Segment client")
        ax2.set_ylabel("CA par transaction (€)")
        ax2.set_xticklabels(ax2.get_xticklabels(), rotation=15)
        ax2.yaxis.set_major_formatter(
            mticker.FuncFormatter(lambda x, _: f"{x:,.0f}€")
        )

        plt.tight_layout()
        self._sauvegarder(fig, "fig_08_paiements_segments", show)

    # ─────────────────────────────────────────────────────────────
    # MÉTHODE : GÉNÉRER TOUTES LES FIGURES
    # ─────────────────────────────────────────────────────────────
    def generer_rapport_complet(self, kpis: Dict, df: pd.DataFrame,
                                 df_cat: pd.DataFrame,
                                 df_temps: pd.DataFrame,
                                 df_produits: pd.DataFrame,
                                 df_rfm: pd.DataFrame,
                                 show: bool = False) -> None:
        """
        Génère tous les graphiques du rapport en une seule fois.
        Par défaut show=False pour ne pas bloquer l'exécution.
        """
        logger.info("Génération du rapport visuel complet...")

        self.fig_kpis_globaux(kpis, show=show)
        self.fig_ca_par_categorie(df_cat, show=show)
        self.fig_evolution_temporelle(df_temps, show=show)
        self.fig_heatmap_pays_categorie(df, show=show)
        self.fig_rfm_segments(df_rfm, show=show)
        self.fig_top_produits(df_produits, show=show)
        self.fig_distributions(df, show=show)
        self.fig_paiements_segments(df, show=show)

        logger.info(f"[OK] {8} graphiques générés dans {self.dossier}/")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PARTIE 5
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ex 5.1 : FACILE — Modifiez fig_ca_par_categorie pour afficher le nombre
  de transactions au lieu du CA. Changez le titre et les labels en conséquence.

Ex 5.2 : INTERMÉDIAIRE — Créez une figure de corrélation entre unit_price
  et chiffre_affaires_ht avec une ligne de régression seaborn.regplot().
  Interprétez le R² de la régression.

Ex 5.3 : AVANCÉ — Créez un graphique Plotly Express interactif (fig.write_html)
  affichant l'évolution mensuelle du CA par pays avec des courbes colorées
  et un sélecteur de pays.

================================================================================
  [GRAPHIQUE] DataInsight Pro — PARTIE 6 — ANALYSE AVANCÉE ET TESTS STATISTIQUES
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
QUESTION BUSINESS : Les remises augmentent-elles réellement les ventes ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# src/analysis.py — Méthodes avancées (ajout à la classe Analyste)

from scipy import stats
import pandas as pd
import numpy as np

class AnalysteAvance:
    """
    Analyses statistiques avancées sur les données EuroShop Commerce.
    """

    # ─────────────────────────────────────────────────────────────
    # TEST 1 : REMISE VS QUANTITÉ
    # ─────────────────────────────────────────────────────────────
    def tester_impact_remise(self, df: pd.DataFrame) -> Dict:
        """
        Test statistique : les commandes remisées ont-elles des quantités
        significativement différentes des commandes non remisées ?

        Hypothèses :
          H0 : La remise n'impacte pas la quantité
          H1 : Les commandes avec remise ont des quantités différentes

        Méthode : Mann-Whitney U (non paramétrique — distribution asymétrique)
        """
        avec_remise    = df[df["est_remise"] == True]["quantity"].dropna()
        sans_remise    = df[df["est_remise"] == False]["quantity"].dropna()

        # Test Mann-Whitney
        u_stat, p_val = stats.mannwhitneyu(
            avec_remise, sans_remise,
            alternative="two-sided"   # Test bilatéral
        )

        resultat = {
            "n_avec_remise":     len(avec_remise),
            "n_sans_remise":     len(sans_remise),
            "mediane_avec":      round(float(avec_remise.median()), 2),
            "mediane_sans":      round(float(sans_remise.median()), 2),
            "U_statistique":     round(float(u_stat), 2),
            "p_value":           round(float(p_val), 6),
            "significatif_5pct": bool(p_val < 0.05),
            "conclusion": (
                "[OK] La remise est associée à des quantités significativement "
                "plus élevées (p < 0.05)"
                if p_val < 0.05 else
                "[X] Aucune différence significative de quantité selon la remise"
            )
        }
        return resultat

    # ─────────────────────────────────────────────────────────────
    # TEST 2 : ANOVA — CA MOYEN PAR PAYS
    # ─────────────────────────────────────────────────────────────
    def anova_ca_par_pays(self, df: pd.DataFrame) -> Dict:
        """
        ANOVA : Le CA moyen par commande est-il différent selon le pays ?

        H0 : μ_France = μ_Allemagne = ... = μ_Pologne
        H1 : Au moins un pays a un CA moyen différent
        """
        groupes = [
            group["chiffre_affaires_ht"].values
            for name, group in df.groupby("country", observed=True)
        ]

        f_stat, p_val = stats.f_oneway(*groupes)  # * = unpacking de la liste

        # Test post-hoc de Tukey (si ANOVA significatif)
        conclusion = {}
        if p_val < 0.05:
            from scipy.stats import tukey_hsd
            result_tukey = tukey_hsd(*groupes)
            pays = list(df["country"].cat.categories)
            paires_sig = []
            for i in range(len(pays)):
                for j in range(i+1, len(pays)):
                    if result_tukey.pvalue[i,j] < 0.05:
                        paires_sig.append(f"{pays[i]} ≠ {pays[j]}")
            conclusion["paires_significatives"] = paires_sig

        return {
            "F_statistique":     round(float(f_stat), 4),
            "p_value":           round(float(p_val), 6),
            "significatif_5pct": bool(p_val < 0.05),
            "conclusion_anova":  (
                "[OK] ANOVA significatif : au moins un pays diffère"
                if p_val < 0.05 else
                "[X] ANOVA non significatif : CA similaires entre pays"
            ),
            **conclusion
        }

    # ─────────────────────────────────────────────────────────────
    # ANALYSE : SAISONNALITÉ
    # ─────────────────────────────────────────────────────────────
    def analyser_saisonnalite(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Analyse la saisonnalité mensuelle des ventes.

        Calcule l'indice saisonnier de chaque mois :
          Indice > 1 -> mois au-dessus de la moyenne
          Indice < 1 -> mois en-dessous de la moyenne
        """
        # CA moyen par mois (toutes années confondues)
        ca_par_mois = df.groupby("mois", observed=True)["chiffre_affaires_ht"].mean()

        # Indice saisonnier = CA_mois / CA_moyen_global
        ca_global_moyen = df["chiffre_affaires_ht"].mean() * df.groupby("mois", observed=True).size().mean() / 30
        # Approximation : CA moyen par jour × 30 jours
        ca_global_moyen = ca_par_mois.mean()   # Simplification

        indices = ca_par_mois / ca_global_moyen

        noms_mois = ["Jan","Fév","Mar","Avr","Mai","Jun",
                     "Jul","Aoû","Sep","Oct","Nov","Déc"]

        resultat = pd.DataFrame({
            "mois":            range(1, 13),
            "nom_mois":        noms_mois,
            "ca_moyen":        ca_par_mois.values.round(2),
            "indice_saisonnierr": indices.values.round(3),
            "interpretation":  [
                "^ Pic" if v > 1.1 else "v Creux" if v < 0.9 else "-> Normal"
                for v in indices
            ]
        })

        return resultat

    # ─────────────────────────────────────────────────────────────
    # ANALYSE : CLV (Customer Lifetime Value)
    # ─────────────────────────────────────────────────────────────
    def calculer_clv(self, df: pd.DataFrame,
                      periode_mois: int = 24) -> pd.DataFrame:
        """
        Calcule la Customer Lifetime Value (valeur vie client).

        Formule simplifiée :
          CLV = CA_moyen_mensuel × durée_relation_mois × marge_brute

        Paramètre :
            periode_mois : période d'observation en mois
        """
        MARGE_BRUTE = 0.35   # Hypothèse : 35% de marge
        TAUX_CHURN_MENSUEL = 0.05   # 5% de clients perdus par mois

        rfm_data = df.groupby("customer_id").agg(
            premiere_cmd  =("date", "min"),
            derniere_cmd  =("date", "max"),
            nb_commandes  =("invoice_id", "count"),
            ca_total      =("chiffre_affaires_ht", "sum"),
        )

        # Durée de la relation en mois
        rfm_data["duree_mois"] = (
            (rfm_data["derniere_cmd"] - rfm_data["premiere_cmd"]).dt.days / 30.44
        ).clip(lower=1)   # Minimum 1 mois

        # CA mensuel moyen
        rfm_data["ca_mensuel"] = rfm_data["ca_total"] / rfm_data["duree_mois"]

        # CLV = CA mensuel × (1/taux_churn) × marge_brute
        # Formule NPV simplifiée : durée attendue = 1/taux_churn
        duree_attendue = 1 / TAUX_CHURN_MENSUEL   # = 20 mois
        rfm_data["clv_estime"] = (
            rfm_data["ca_mensuel"] * duree_attendue * MARGE_BRUTE
        ).round(2)

        # Percentiles CLV
        p25 = rfm_data["clv_estime"].quantile(0.25)
        p75 = rfm_data["clv_estime"].quantile(0.75)

        rfm_data["segment_clv"] = pd.cut(
            rfm_data["clv_estime"],
            bins=[0, p25, p75, float("inf")],
            labels=["Faible CLV", "Moyenne CLV", "Haute CLV"]
        )

        return rfm_data.reset_index()
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PARTIE 6
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Ex 6.1 : FACILE — Calculez l'indice de saisonnalité et identifiez les 3 mois
  avec le plus fort indice et les 3 avec le plus faible.

Ex 6.2 : INTERMÉDIAIRE — Testez si le CA moyen est significativement différent
  selon le mode de paiement (ANOVA). Si oui, quels modes diffèrent (Tukey) ?

Ex 6.3 : AVANCÉ — Créez un modèle de régression linéaire simple (sklearn)
  pour prédire le CA par commande en fonction de unit_price, quantity,
  discount_pct et catégorie. Calculez le R². Interprétez les coefficients.

================================================================================
FIN PARTIES 5 & 6 — Prochaine étape : Parties 7 & 8 — Cas business + Projet final
================================================================================

================================================================================
  [GRAPHIQUE] DataInsight Pro — Plateforme Professionnelle d'Analyse de Données
  PARTIE 7 — CAS BUSINESS RÉELS
================================================================================

"La donnée ne vaut rien sans la question business derrière elle."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Vous présentez vos analyses au COMITÉ EXÉCUTIF d'EuroShop Commerce.
Il y a 3 directeurs dans la salle, chacun avec UNE question business urgente.

DIRECTEUR COMMERCIAL :
  "Nos campagnes de remise coûtent 180 000 € par an.
   Sont-elles rentables ? Quels segments relancer en priorité ?"

DIRECTEUR MARKETING :
  "On veut lancer des campagnes email personnalisées.
   Comment segmenter nos 2 500 clients ? Par quoi commencer ?"

DIRECTEUR FINANCIER :
  "Notre CA 2023 est-il en croissance vs 2022 ?
   Quels mois sont nos pics et nos creux ? Peut-on prévoir 2024 ?"

Votre rôle : répondre avec des données, des graphiques et des recommandations
chiffrées. Pas d'opinions — que des faits mesurables.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [OK] Traduire une question business en requête analytique
  [OK] Construire un A/B test complet sur données réelles
  [OK] Implémenter une segmentation client RFM opérationnelle
  [OK] Produire une prévision de CA simple et interprétable
  [OK] Rédiger des recommandations business chiffrées
  [OK] Créer un rapport exécutif professionnel

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  CAS BUSINESS 1 — RENTABILITÉ DES REMISES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QUESTION : "Les remises génèrent-elles un CA supplémentaire qui compense leur coût ?"

```python
# cas_business_1_remises.py
"""
Analyse complète de la rentabilité des remises commerciales.
Dataset : EuroShop Commerce 2022-2023.
"""

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
from sklearn.linear_model import LinearRegression
import warnings
warnings.filterwarnings('ignore')

# ── Chargement et nettoyage (résumé) ─────────────────────────────
df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")
df["date"]     = pd.to_datetime(df["date"], errors="coerce")
df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
df["discount_pct"] = df["discount_pct"].fillna(0.0)
df["quantity"]     = df.groupby("category")["quantity"].transform(
    lambda x: x.fillna(x.median())
)
df = df[(df["unit_price"] > 0) & (df["quantity"] > 0)]
df = df[(df["date"] >= "2022-01-01") & (df["date"] <= "2023-12-31")]
df["chiffre_affaires_ht"] = (df["unit_price"] * df["quantity"] * (1 - df["discount_pct"])).round(2)
df["est_remise"] = df["discount_pct"] > 0

# ═══════════════════════════════════════════════════════════════════
# ANALYSE 1 : VOLUME ET CA — REMISES VS SANS REMISE
# ═══════════════════════════════════════════════════════════════════

print("="*65)
print("CAS BUSINESS 1 — RENTABILITÉ DES REMISES COMMERCIALES")
print("="*65)

# Segmenter les commandes avec/sans remise
avec_remise  = df[df["est_remise"] == True]
sans_remise  = df[df["est_remise"] == False]

# Métriques comparatives
tableau = pd.DataFrame({
    "Groupe":           ["Avec remise", "Sans remise"],
    "Nb_commandes":     [len(avec_remise), len(sans_remise)],
    "CA_total":         [avec_remise["chiffre_affaires_ht"].sum(),
                          sans_remise["chiffre_affaires_ht"].sum()],
    "CA_moyen":         [avec_remise["chiffre_affaires_ht"].mean(),
                          sans_remise["chiffre_affaires_ht"].mean()],
    "Qte_moyenne":      [avec_remise["quantity"].mean(),
                          sans_remise["quantity"].mean()],
    "Prix_moyen":       [avec_remise["unit_price"].mean(),
                          sans_remise["unit_price"].mean()],
    "Remise_moyenne":   [avec_remise["discount_pct"].mean(),
                          0.0],
}).set_index("Groupe")

tableau["CA_total"]    = tableau["CA_total"].round(2)
tableau["CA_moyen"]    = tableau["CA_moyen"].round(2)
tableau["Qte_moyenne"] = tableau["Qte_moyenne"].round(2)
tableau["Prix_moyen"]  = tableau["Prix_moyen"].round(2)

print("\n── Tableau comparatif ────────────────────────────────────")
print(tableau.to_string())

# ═══════════════════════════════════════════════════════════════════
# ANALYSE 2 : TEST STATISTIQUE MANN-WHITNEY (CA MOYEN)
# ═══════════════════════════════════════════════════════════════════

print("\n── Test statistique (Mann-Whitney U) ─────────────────────")
# H0 : CA moyen identique avec/sans remise
# H1 : CA moyen différent
u_stat, p_val = stats.mannwhitneyu(
    avec_remise["chiffre_affaires_ht"],
    sans_remise["chiffre_affaires_ht"],
    alternative="two-sided"
)
print(f"U-stat  = {u_stat:.2f}")
print(f"p-value = {p_val:.6f}")
print(f"Résultat : {'[OK] DIFFÉRENCE SIGNIFICATIVE' if p_val < 0.05 else '[X] Pas de différence'}")

# ═══════════════════════════════════════════════════════════════════
# ANALYSE 3 : COÛT DES REMISES VS GAIN DE VOLUME
# ═══════════════════════════════════════════════════════════════════

print("\n── Analyse coût/bénéfice des remises ─────────────────────")

# Calculer ce qu'aurait été le CA SANS remise (prix plein)
ca_sans_remise_theorique = (
    avec_remise["unit_price"] * avec_remise["quantity"]
).sum()

# CA réel des commandes remisées
ca_avec_remise_reel = avec_remise["chiffre_affaires_ht"].sum()

# Coût des remises = différence
cout_remises = ca_sans_remise_theorique - ca_avec_remise_reel

# Volume supplémentaire généré par les remises
# Hypothèse : sans remise, la quantité moyenne serait la même que "sans remise"
qte_supplementaire = (
    avec_remise["quantity"].mean() - sans_remise["quantity"].mean()
) * len(avec_remise)

print(f"CA théorique sans remise (sur les cmds remisées) : {ca_sans_remise_theorique:>12,.0f} €")
print(f"CA réel des commandes remisées                   : {ca_avec_remise_reel:>12,.0f} €")
print(f"Coût total des remises accordées                 : {cout_remises:>12,.0f} €")
print(f"Quantité supplémentaire estimée grâce aux remises: {qte_supplementaire:>12,.0f} unités")

# ROI simplifié de la politique de remises
marge_brute = 0.35   # 35% de marge
gain_marge_volume = qte_supplementaire * sans_remise["unit_price"].mean() * marge_brute
roi = (gain_marge_volume - cout_remises) / cout_remises * 100 if cout_remises > 0 else 0

print(f"\nGain marge sur volume supplémentaire             : {gain_marge_volume:>12,.0f} €")
print(f"ROI estimé de la politique de remises            : {roi:>12.1f} %")
print(f"Conclusion : {'[OK] Les remises sont rentables' if roi > 0 else '[X] Les remises coûtent plus qu elles ne rapportent'}")

# ═══════════════════════════════════════════════════════════════════
# ANALYSE 4 : REMISES PAR CATÉGORIE — OÙ SONT LES PLUS EFFICACES ?
# ═══════════════════════════════════════════════════════════════════

print("\n── Efficacité des remises par catégorie ──────────────────")

analyse_cat = df.groupby(["category", "est_remise"], observed=True).agg(
    ca_moyen =("chiffre_affaires_ht", "mean"),
    qte_moy  =("quantity", "mean"),
    n_cmds   =("invoice_id", "count"),
).round(2)

# Pivot : comparer avec/sans remise par catégorie
pivot_remise = analyse_cat["ca_moyen"].unstack(level="est_remise")
pivot_remise.columns = ["CA_sans_remise", "CA_avec_remise"]
pivot_remise["uplift_pct"] = (
    (pivot_remise["CA_avec_remise"] - pivot_remise["CA_sans_remise"]) /
    pivot_remise["CA_sans_remise"] * 100
).round(1)
pivot_remise = pivot_remise.sort_values("uplift_pct", ascending=False)

print("\nUplift du CA moyen grâce aux remises, par catégorie :")
print(pivot_remise.to_string())

# ═══════════════════════════════════════════════════════════════════
# ANALYSE 5 : RÉGRESSION — QUEL TAUX DE REMISE EST OPTIMAL ?
# ═══════════════════════════════════════════════════════════════════

print("\n── Régression : taux de remise optimal ───────────────────")

# Modèle : CA ~ discount_pct + unit_price + quantity
from sklearn.preprocessing import StandardScaler

X = df[["discount_pct", "unit_price", "quantity"]].dropna()
y = df.loc[X.index, "chiffre_affaires_ht"]

lr = LinearRegression()
lr.fit(X, y)
r2 = lr.score(X, y)

print(f"R² du modèle : {r2:.4f}")
print("Coefficients :")
for col, coef in zip(["discount_pct", "unit_price", "quantity"], lr.coef_):
    sens = "^ CA" if coef > 0 else "v CA"
    print(f"  {col:15s} : {coef:+.4f}  {sens}")

# Simulation : CA prédit pour différents taux de remise
print("\nSimulation CA prédit par taux de remise :")
prix_moyen = df["unit_price"].mean()
qte_moyenné = df["quantity"].mean()
for remise in [0, 0.05, 0.10, 0.15, 0.20]:
    ca_predit = lr.predict([[remise, prix_moyen, qte_moyenné]])[0]
    print(f"  Remise {remise*100:.0f}% : CA prédit = {ca_predit:,.0f} €")

# ═══════════════════════════════════════════════════════════════════
# VISUALISATION : DASHBOARD REMISES
# ═══════════════════════════════════════════════════════════════════

fig, axes = plt.subplots(2, 2, figsize=(16, 10))
fig.suptitle("Analyse Rentabilité des Remises — EuroShop Commerce",
             fontsize=14, fontweight="bold")

# 1. Distribution du CA selon remise (violinplot)
ax1 = axes[0][0]
sns.violinplot(data=df, x="est_remise", y="chiffre_affaires_ht",
               palette=["#2196F3", "#FF5722"], inner="quartile", ax=ax1)
ax1.set_xticklabels(["Sans remise", "Avec remise"])
ax1.set_title("Distribution du CA par commande")
ax1.set_ylabel("CA HT (€)")
ax1.set_xlabel("")

# 2. Uplift par catégorie
ax2 = axes[0][1]
colors = ["#4CAF50" if v > 0 else "#F44336" for v in pivot_remise["uplift_pct"]]
pivot_remise["uplift_pct"].plot(kind="bar", ax=ax2, color=colors, edgecolor="white")
ax2.axhline(0, color="black", linewidth=1)
ax2.set_title("Uplift CA avec remise vs sans remise (%)")
ax2.set_xlabel("Catégorie")
ax2.set_ylabel("Uplift (%)")
ax2.tick_params(axis="x", rotation=30)

# 3. Scatter : taux remise vs CA
ax3 = axes[1][0]
sample = df.sample(min(2000, len(df)), random_state=42)
ax3.scatter(sample["discount_pct"] * 100, sample["chiffre_affaires_ht"],
            alpha=0.3, s=15, color="#2196F3")
# Courbe de tendance
remises_sorted = np.linspace(0, 20, 100)
ca_pred = [lr.predict([[r/100, prix_moyen, qte_moyenné]])[0] for r in remises_sorted]
ax3.plot(remises_sorted, ca_pred, "r-", linewidth=2, label="Tendance")
ax3.set_xlabel("Taux de remise (%)")
ax3.set_ylabel("CA par commande (€)")
ax3.set_title("Relation remise <-> CA (+ tendance)")
ax3.legend()

# 4. Répartition coût/gain
ax4 = axes[1][1]
categories_kpi = ["Coût remises\naccordées", "Gain marge\nvolume supp."]
valeurs_kpi = [cout_remises, gain_marge_volume]
couleurs_kpi = ["#F44336", "#4CAF50"]
barres = ax4.bar(categories_kpi, valeurs_kpi, color=couleurs_kpi, edgecolor="white")
for b, v in zip(barres, valeurs_kpi):
    ax4.text(b.get_x() + b.get_width()/2, b.get_height() + 500,
             f"{v:,.0f} €", ha="center", fontsize=11, fontweight="bold")
ax4.set_title(f"Coût vs Gain des remises (ROI ≈ {roi:.0f}%)")
ax4.set_ylabel("Montant (€)")

plt.tight_layout()
plt.savefig("reports/cas1_remises.png", dpi=150, bbox_inches="tight")
plt.show()

# ═══════════════════════════════════════════════════════════════════
# RECOMMANDATIONS BUSINESS
# ═══════════════════════════════════════════════════════════════════

print(f"""
╔══════════════════════════════════════════════════════════════════╗
║  RECOMMANDATIONS — Directeur Commercial                         ║
╠══════════════════════════════════════════════════════════════════╣

[GRAPHIQUE] CONSTAT :
   • {len(avec_remise):,} commandes avec remise ({len(avec_remise)/len(df)*100:.1f}% du total)
   • Coût des remises accordées : {cout_remises:,.0f} €
   • ROI estimé : {roi:.0f}%

[OK] ACTIONS RECOMMANDÉES :
   1. Maintenir les remises sur {pivot_remise[pivot_remise['uplift_pct']>0].index.tolist()[:3]}
      -> Ces catégories montrent un uplift positif du CA

   2. RÉDUIRE les remises sur {pivot_remise[pivot_remise['uplift_pct']<0].index.tolist()[:2]}
      -> Ces catégories ont un CA PLUS FAIBLE avec remise (clients moins engagés ?)

   3. Seuil optimal : remise ≤ 10%
      -> Au-delà de 10%, le gain de volume ne compense plus le manque à gagner

   4. Cibler les PME et Grandes entreprises pour les remises volume
      -> Ces segments ont les quantités les plus élevées

╚══════════════════════════════════════════════════════════════════╝
""")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  CAS BUSINESS 2 — SEGMENTATION CLIENTS ET CIBLAGE MARKETING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QUESTION : "Comment segmenter nos clients pour personnaliser nos campagnes ?"

```python
# cas_business_2_segmentation.py
"""
Segmentation client opérationnelle avec RFM + clustering K-Means.
"""

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
import warnings
warnings.filterwarnings('ignore')

# ── Charger les données nettoyées ────────────────────────────────
df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
df["discount_pct"] = df["discount_pct"].fillna(0.0)
df["quantity"] = df.groupby("category")["quantity"].transform(lambda x: x.fillna(x.median()))
df = df[(df["unit_price"] > 0) & (df["quantity"] > 0) &
        (df["date"] >= "2022-01-01") & (df["date"] <= "2023-12-31")]
df["chiffre_affaires_ht"] = (df["unit_price"] * df["quantity"] * (1 - df["discount_pct"])).round(2)

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 1 : CALCUL RFM
# ═══════════════════════════════════════════════════════════════════

DATE_REF = df["date"].max() + pd.Timedelta(days=1)

rfm = df.groupby("customer_id").agg(
    recency   = ("date",                 lambda x: (DATE_REF - x.max()).days),
    frequency = ("invoice_id",           "count"),
    monetary  = ("chiffre_affaires_ht",  "sum"),
).round(2)

print(f"RFM calculé pour {len(rfm):,} clients")
print(rfm.describe().round(2))

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 2 : NORMALISATION POUR K-MEANS
# ═══════════════════════════════════════════════════════════════════

# K-Means est sensible aux échelles -> OBLIGATOIRE de normaliser
# StandardScaler : centre (µ=0) et réduit (σ=1) chaque feature
scaler = StandardScaler()

# .fit_transform() = apprendre les paramètres + transformer
# Équivalent à : scaler.fit(X) ; X_scaled = scaler.transform(X)
rfm_scaled = scaler.fit_transform(rfm[["recency", "frequency", "monetary"]])

# ─────────────────────────────────────────────────────────────────
# ÉTAPE 3 : MÉTHODE DU COUDE — TROUVER K OPTIMAL
# ─────────────────────────────────────────────────────────────────

# Pour chaque K, on calcule :
# - Inertie (WCSS) : somme des distances carrées intra-cluster -> à minimiser
# - Silhouette : mesure de cohésion des clusters [-1, 1] -> à maximiser

inertia   = []
silhouettes = []
K_range   = range(2, 9)

print("\nRecherche du K optimal...")
for k in K_range:
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = km.fit_predict(rfm_scaled)
    inertia.append(km.inertia_)
    sil = silhouette_score(rfm_scaled, labels, sample_size=1000)
    silhouettes.append(sil)
    print(f"  K={k} : Inertie={km.inertia_:.1f}, Silhouette={sil:.4f}")

# Visualiser la méthode du coude
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
axes[0].plot(K_range, inertia, "bo-", linewidth=2)
axes[0].set_xlabel("Nombre de clusters K")
axes[0].set_ylabel("Inertie (WCSS)")
axes[0].set_title("Méthode du coude — Trouver K optimal")
axes[0].axvline(4, color="red", linestyle="--", label="K=4 choisi")
axes[0].legend()

axes[1].plot(K_range, silhouettes, "go-", linewidth=2)
axes[1].set_xlabel("Nombre de clusters K")
axes[1].set_ylabel("Score Silhouette")
axes[1].set_title("Score Silhouette par K")
axes[1].axvline(silhouettes.index(max(silhouettes)) + 2, color="red",
                linestyle="--", label=f"K={silhouettes.index(max(silhouettes))+2}")
axes[1].legend()

plt.tight_layout()
plt.savefig("reports/cas2_methode_coude.png", dpi=150, bbox_inches="tight")
plt.show()

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 4 : K-MEANS AVEC K=4 (CHOIX MÉTIER)
# ═══════════════════════════════════════════════════════════════════

K_CHOISI = 4
km_final = KMeans(n_clusters=K_CHOISI, random_state=42, n_init=20)
rfm["cluster"] = km_final.fit_predict(rfm_scaled)
rfm["cluster"]  = rfm["cluster"].astype(str)

# Profil de chaque cluster (dénormalisé)
profil_clusters = rfm.groupby("cluster").agg(
    n_clients  =("recency",   "count"),
    recency_moy=("recency",   "mean"),
    freq_moy   =("frequency", "mean"),
    monetary_moy=("monetary", "mean"),
).round(2)
profil_clusters["pct_clients"] = (profil_clusters["n_clients"] / len(rfm) * 100).round(1)

print("\nProfil des clusters K-Means :")
print(profil_clusters.to_string())

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 5 : NOMMER LES SEGMENTS (RÈGLES MÉTIER)
# ═══════════════════════════════════════════════════════════════════

# On attribue un nom métier à chaque cluster selon son profil RFM
def nommer_cluster(row):
    """
    Nomme un cluster selon ses caractéristiques RFM.
    Utilise des seuils calculés sur l'ensemble du dataset.
    """
    r_med = rfm["recency"].median()    # Seuil récence
    f_med = rfm["frequency"].median()  # Seuil fréquence
    m_med = rfm["monetary"].median()   # Seuil valeur

    r = row["recency_moy"]
    f = row["freq_moy"]
    m = row["monetary_moy"]

    if r < r_med and f >= f_med and m >= m_med:
        return "[TROPHEE] Champions"
    elif r < r_med and f >= f_med:
        return "* Fidèles"
    elif r < r_med:
        return "[NOUVEAU] Récents"
    elif m >= m_med:
        return "[ATTENTE] Dormants à valeur"
    else:
        return "[SNOWFLAKE] Perdus"

profil_clusters["segment_nom"] = profil_clusters.apply(nommer_cluster, axis=1)
print("\nSegments nommés :")
print(profil_clusters[["segment_nom", "n_clients", "pct_clients",
                         "recency_moy", "freq_moy", "monetary_moy"]].to_string())

# Joindre les noms au DataFrame client
mapping_nom = profil_clusters["segment_nom"].to_dict()
rfm["segment"] = rfm["cluster"].map(mapping_nom)

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 6 : VISUALISATION 3D RFM
# ═══════════════════════════════════════════════════════════════════

fig = plt.figure(figsize=(16, 6))

# Vue 2D : Recency vs Monetary
ax1 = fig.add_subplot(131)
palette_seg = {
    "[TROPHEE] Champions": "#FFD700",
    "* Fidèles": "#4CAF50",
    "[NOUVEAU] Récents": "#2196F3",
    "[ATTENTE] Dormants à valeur": "#FF9800",
    "[SNOWFLAKE] Perdus": "#9E9E9E"
}
for seg, grp in rfm.groupby("segment"):
    ax1.scatter(grp["recency"], grp["monetary"],
                label=seg, alpha=0.5, s=20,
                color=palette_seg.get(seg, "gray"))
ax1.set_xlabel("Récence (jours)")
ax1.set_ylabel("Valeur Totale (€)")
ax1.set_title("Récence vs Valeur")
ax1.legend(fontsize=7)

# Vue 2D : Frequency vs Monetary
ax2 = fig.add_subplot(132)
for seg, grp in rfm.groupby("segment"):
    ax2.scatter(grp["frequency"], grp["monetary"],
                label=seg, alpha=0.5, s=20,
                color=palette_seg.get(seg, "gray"))
ax2.set_xlabel("Fréquence (nb commandes)")
ax2.set_ylabel("Valeur Totale (€)")
ax2.set_title("Fréquence vs Valeur")

# Vue radar : Profil moyen des segments
ax3 = fig.add_subplot(133)
counts = rfm["segment"].value_counts()
wedge_colors = [palette_seg.get(s, "gray") for s in counts.index]
ax3.pie(counts.values, labels=counts.index,
        colors=wedge_colors, autopct="%1.1f%%",
        startangle=90, pctdistance=0.8, textprops={"fontsize": 7})
ax3.set_title("Répartition des segments")

plt.tight_layout()
plt.savefig("reports/cas2_segmentation.png", dpi=150, bbox_inches="tight")
plt.show()

# ═══════════════════════════════════════════════════════════════════
# PLAN D'ACTION MARKETING PAR SEGMENT
# ═══════════════════════════════════════════════════════════════════

plan_actions = {
    "[TROPHEE] Champions": {
        "action": "Programme VIP — Fidélisation premium",
        "canal": "Email personnalisé + téléphone",
        "offre": "Accès avant-première, remise exclusive 5%",
        "fréquence": "Mensuelle",
        "budget_contact": "5€/client"
    },
    "* Fidèles": {
        "action": "Programme de parrainage",
        "canal": "Email + SMS",
        "offre": "Bon parrainage 20€ pour eux et leur filleul",
        "fréquence": "Bimestrielle",
        "budget_contact": "2€/client"
    },
    "[NOUVEAU] Récents": {
        "action": "Onboarding — Montée en gamme",
        "canal": "Email automatisé (séquence 3 emails)",
        "offre": "Présentation catalogue complet, remise 1ère commande 10€",
        "fréquence": "Hebdomadaire (3 semaines)",
        "budget_contact": "1€/client"
    },
    "[ATTENTE] Dormants à valeur": {
        "action": "Campagne réactivation urgente",
        "canal": "Email + retargeting pub",
        "offre": "Offre limitée 15% — valable 7 jours",
        "fréquence": "Intensive sur 2 semaines",
        "budget_contact": "3€/client"
    },
    "[SNOWFLAKE] Perdus": {
        "action": "Campagne win-back ou désabonnement",
        "canal": "Email unique + nettoyage liste",
        "offre": "Dernière chance — 25% remise ou désinscription",
        "fréquence": "Une seule fois",
        "budget_contact": "0.5€/client"
    },
}

print("\n" + "═"*65)
print("PLAN D'ACTIONS MARKETING PAR SEGMENT")
print("═"*65)

for segment, action in plan_actions.items():
    n = (rfm["segment"] == segment).sum()
    pct = n / len(rfm) * 100
    ca_moy = rfm[rfm["segment"] == segment]["monetary"].mean()
    budget = n * float(action["budget_contact"].replace("€/client", ""))

    print(f"\n{segment} — {n} clients ({pct:.1f}%)")
    print(f"  CA moyen     : {ca_moy:,.0f} €")
    print(f"  Action       : {action['action']}")
    print(f"  Canal        : {action['canal']}")
    print(f"  Offre        : {action['offre']}")
    print(f"  Budget total : {budget:,.0f} €")

budget_total = sum(
    (rfm["segment"] == seg).sum() *
    float(act["budget_contact"].replace("€/client", ""))
    for seg, act in plan_actions.items()
)
print(f"\nBudget total campagne : {budget_total:,.0f} €")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  CAS BUSINESS 3 — PRÉVISION DE CA 2024
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

QUESTION : "Quel CA peut-on prévoir pour 2024 ? Quels mois seront nos pics ?"

```python
# cas_business_3_prevision.py
"""
Prévision du CA 2024 avec décomposition saisonnière et régression linéaire.
Méthodes : régression sur tendance + indices saisonniers (méthode classique).
"""

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score, mean_absolute_error
import warnings
warnings.filterwarnings('ignore')

# ── Charger et préparer ───────────────────────────────────────────
df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
df["discount_pct"] = df["discount_pct"].fillna(0.0)
df["quantity"] = df.groupby("category")["quantity"].transform(lambda x: x.fillna(x.median()))
df = df[(df["unit_price"] > 0) & (df["quantity"] > 0) &
        (df["date"] >= "2022-01-01") & (df["date"] <= "2023-12-31")]
df["chiffre_affaires_ht"] = (df["unit_price"] * df["quantity"] * (1 - df["discount_pct"])).round(2)

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 1 : SÉRIES TEMPORELLES MENSUELLES
# ═══════════════════════════════════════════════════════════════════

# Regrouper par mois
ca_mensuel = df.set_index("date").resample("ME")["chiffre_affaires_ht"].sum()
ca_mensuel = ca_mensuel.reset_index()
ca_mensuel.columns = ["date", "ca"]
ca_mensuel["mois_num"] = range(1, len(ca_mensuel) + 1)   # 1, 2, ..., 24
ca_mensuel["mois"] = ca_mensuel["date"].dt.month          # 1..12
ca_mensuel["annee"] = ca_mensuel["date"].dt.year

print(f"Périodes mensuelles : {len(ca_mensuel)}")
print(ca_mensuel.head(6).to_string())

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 2 : RÉGRESSION LINÉAIRE SUR LA TENDANCE
# ═══════════════════════════════════════════════════════════════════

# Modèle : CA = a × mois_num + b  (tendance linéaire)
X_trend = ca_mensuel[["mois_num"]]
y_trend = ca_mensuel["ca"]

lr_trend = LinearRegression()
lr_trend.fit(X_trend, y_trend)

ca_mensuel["tendance"] = lr_trend.predict(X_trend)
ca_mensuel["residus"]  = ca_mensuel["ca"] - ca_mensuel["tendance"]

r2 = r2_score(y_trend, ca_mensuel["tendance"])
pente = lr_trend.coef_[0]

print(f"\nRégression tendance :")
print(f"  R²    = {r2:.4f}")
print(f"  Pente = {pente:+.0f} €/mois (CA croît de {pente:.0f}€ par mois en moyenne)")

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 3 : INDICES SAISONNIERS
# ═══════════════════════════════════════════════════════════════════

# Indice saisonnier = CA_mois / Tendance_mois (moyenne par numéro de mois)
ca_mensuel["indice_saisonnier_brut"] = ca_mensuel["ca"] / ca_mensuel["tendance"]

# Moyenne des indices pour chaque mois (1..12)
indices_saison = ca_mensuel.groupby("mois")["indice_saisonnier_brut"].mean()

# Normalisation : la somme des 12 indices doit = 12
# (Pour que la moyenne soit = 1 -> pas de biais)
indices_saison = indices_saison / indices_saison.mean()

noms_mois = ["Jan","Fév","Mar","Avr","Mai","Jun",
             "Jul","Aoû","Sep","Oct","Nov","Déc"]
print("\nIndices saisonniers (1.0 = mois moyen) :")
for mois, idx in indices_saison.items():
    barre = "[BLACK_UP-POINTING_TRIANGLE]" if idx > 1.05 else "[BLACK_DOWN-POINTING_TRIANGLE]" if idx < 0.95 else "━"
    print(f"  {noms_mois[mois-1]:4s} : {idx:.3f}  {barre}  "
          f"{'Pic +' if idx > 1.05 else 'Creux -' if idx < 0.95 else 'Normal '}"
          f"{abs((idx-1)*100):.1f}%")

# ═══════════════════════════════════════════════════════════════════
# ÉTAPE 4 : PRÉVISION 2024 (MOIS 25 à 36)
# ═══════════════════════════════════════════════════════════════════

# Créer les 12 prochains mois (janvier à décembre 2024)
previsions_2024 = pd.DataFrame({
    "mois_num": range(25, 37),     # Continuation de la numérotation
    "mois":     range(1, 13),
    "annee":    2024,
    "date":     pd.date_range("2024-01-31", periods=12, freq="ME")
})

# Prévision = Tendance × Indice saisonnier
previsions_2024["tendance_prevue"] = lr_trend.predict(previsions_2024[["mois_num"]])
previsions_2024["indice_saison"]   = previsions_2024["mois"].map(indices_saison)
previsions_2024["ca_prevu"]        = (
    previsions_2024["tendance_prevue"] * previsions_2024["indice_saison"]
).round(0)

# Intervalles de confiance ±15% (incertitude de prévision)
previsions_2024["ca_min"] = (previsions_2024["ca_prevu"] * 0.85).round(0)
previsions_2024["ca_max"] = (previsions_2024["ca_prevu"] * 1.15).round(0)

print("\nPrévisions CA mensuel 2024 :")
print(f"{'Mois':6s} {'CA Prévu':>12s} {'IC Min':>10s} {'IC Max':>10s} {'Indice':>8s}")
print("─"*52)
for _, row in previsions_2024.iterrows():
    mois_nom = noms_mois[int(row["mois"]) - 1]
    print(f"{mois_nom:6s} {row['ca_prevu']:>12,.0f} € "
          f"{row['ca_min']:>10,.0f} € "
          f"{row['ca_max']:>10,.0f} € "
          f"{row['indice_saison']:>8.3f}")

ca_annuel_2024 = previsions_2024["ca_prevu"].sum()
ca_annuel_2022 = ca_mensuel[ca_mensuel["annee"]==2022]["ca"].sum()
ca_annuel_2023 = ca_mensuel[ca_mensuel["annee"]==2023]["ca"].sum()
croissance_yoy = (ca_annuel_2024 - ca_annuel_2023) / ca_annuel_2023 * 100

print(f"\nCA annuel 2022 : {ca_annuel_2022:>12,.0f} €")
print(f"CA annuel 2023 : {ca_annuel_2023:>12,.0f} €  "
      f"(+{(ca_annuel_2023/ca_annuel_2022-1)*100:.1f}% vs 2022)")
print(f"CA prévu  2024 : {ca_annuel_2024:>12,.0f} €  "
      f"(+{croissance_yoy:.1f}% vs 2023)")

# ═══════════════════════════════════════════════════════════════════
# VISUALISATION COMPLÈTE
# ═══════════════════════════════════════════════════════════════════

fig, axes = plt.subplots(2, 2, figsize=(16, 10))
fig.suptitle("Prévision CA 2024 — EuroShop Commerce",
             fontsize=14, fontweight="bold")

# 1. Série historique + tendance + prévision
ax1 = axes[0][0]
ax1.plot(ca_mensuel["date"], ca_mensuel["ca"], "b-o",
         label="CA réel 2022-2023", linewidth=2, markersize=4)
ax1.plot(ca_mensuel["date"], ca_mensuel["tendance"], "r--",
         label="Tendance linéaire", linewidth=1.5)
ax1.plot(previsions_2024["date"], previsions_2024["ca_prevu"], "g-o",
         label="Prévision 2024", linewidth=2, markersize=4)
ax1.fill_between(previsions_2024["date"],
                  previsions_2024["ca_min"], previsions_2024["ca_max"],
                  alpha=0.2, color="green", label="Intervalle ±15%")
ax1.set_title("Évolution CA + Prévision 2024")
ax1.set_ylabel("CA HT (€)")
ax1.legend(fontsize=8)
ax1.tick_params(axis="x", rotation=30)

# 2. Indices saisonniers
ax2 = axes[0][1]
colors_idx = ["#F44336" if v > 1.05 else "#4CAF50" if v < 0.95 else "#2196F3"
               for v in indices_saison.values]
ax2.bar(noms_mois, indices_saison.values, color=colors_idx, edgecolor="white")
ax2.axhline(1.0, color="black", linewidth=1.5, linestyle="--", label="Mois moyen")
ax2.set_title("Indices saisonniers (1.0 = moyenne)")
ax2.set_ylabel("Indice saisonnier")
ax2.legend()
ax2.tick_params(axis="x", rotation=30)

# 3. Comparaison CA par année
ax3 = axes[1][0]
x = np.arange(12)
w = 0.28
ca_2022 = ca_mensuel[ca_mensuel["annee"]==2022]["ca"].values
ca_2023 = ca_mensuel[ca_mensuel["annee"]==2023]["ca"].values
ca_2024 = previsions_2024["ca_prevu"].values
ax3.bar(x - w, ca_2022, w, label="2022", color="#2196F3", alpha=0.8)
ax3.bar(x,     ca_2023, w, label="2023", color="#FF5722", alpha=0.8)
ax3.bar(x + w, ca_2024, w, label="2024 (prévu)", color="#4CAF50",
        alpha=0.8, hatch="//")
ax3.set_xticks(x)
ax3.set_xticklabels(noms_mois, rotation=30)
ax3.set_title("CA mensuel par année (réel vs prévu)")
ax3.set_ylabel("CA HT (€)")
ax3.legend()

# 4. Décomposition trend/saisonnalité/résidus
ax4 = axes[1][1]
ax4.bar(ca_mensuel["date"], ca_mensuel["residus"],
        color=["#F44336" if v < 0 else "#4CAF50" for v in ca_mensuel["residus"]],
        alpha=0.7, width=20)
ax4.axhline(0, color="black", linewidth=1)
ax4.set_title(f"Résidus (CA réel - Tendance)\nR² tendance = {r2:.3f}")
ax4.set_ylabel("Résidu (€)")
ax4.tick_params(axis="x", rotation=30)

plt.tight_layout()
plt.savefig("reports/cas3_prevision.png", dpi=150, bbox_inches="tight")
plt.show()

print(f"""
╔══════════════════════════════════════════════════════════════════╗
║  RECOMMANDATIONS — Directeur Financier                          ║
╠══════════════════════════════════════════════════════════════════╣

[GRAPHIQUE] CROISSANCE :
   2022 -> 2023 : +{(ca_annuel_2023/ca_annuel_2022-1)*100:.1f}%
   2023 -> 2024 : +{croissance_yoy:.1f}% (projection)

[HAUSSE] PICS 2024 IDENTIFIÉS :
   {', '.join([noms_mois[i-1] for i, idx in indices_saison.items() if idx > 1.05][:3])}
   -> Augmenter les stocks et les effectifs sur ces périodes

[BAISSE] CREUX 2024 IDENTIFIÉS :
   {', '.join([noms_mois[i-1] for i, idx in indices_saison.items() if idx < 0.95][:3])}
   -> Opportunité pour campagnes promotionnelles et réductions de coûts

[ATTENTION]  LIMITES DE LA PRÉVISION :
   • Modèle de base (tendance linéaire + saisonnalité)
   • Précision estimée : ±15%
   • Ne prend pas en compte les chocs externes (crise, concurrence)
   • Recommandation : mettre à jour le modèle chaque trimestre

╚══════════════════════════════════════════════════════════════════╝
""")
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  EXPLICATION LIGNE PAR LIGNE — CONCEPTS CLÉS PARTIE 7
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LIGNE : scaler = StandardScaler() ; rfm_scaled = scaler.fit_transform(rfm[...])
  StandardScaler   -> normalise chaque colonne : (valeur - moyenne) / écart-type
  .fit_transform() -> en 2 temps :
    1. .fit() : calcule moyenne et écart-type sur les données
    2. .transform() : applique la normalisation
  Résultat : chaque colonne a μ=0 et σ=1 -> K-Means peut comparer equitablement

LIGNE : km = KMeans(n_clusters=k, random_state=42, n_init=10)
  KMeans           -> algorithme de clustering partitionnel
  n_clusters=k     -> nombre de groupes à former
  random_state=42  -> graine aléatoire -> résultats reproductibles
  n_init=10        -> lancer 10 initialisations -> garder la meilleure
  .inertia_        -> somme des carrés des distances au centroïde (WCSS)

LIGNE : ca_mensuel = df.set_index("date").resample("ME")["chiffre_affaires_ht"].sum()
  .set_index("date") -> utiliser la date comme index (requis par resample)
  .resample("ME")    -> regrouper par mois (Month End)
  ["chiffre_affaires_ht"] -> sélectionner la colonne
  .sum()             -> sommer les CA de toutes les transactions du mois

LIGNE : indices_saison = indices_saison / indices_saison.mean()
  Normalisation des indices saisonniers :
  Avant normalisation : la somme ≠ 12 (biais possible)
  Après normalisation : mean(indices) = 1.0 (référence = mois moyen)
  Un indice de 1.20 signifie "ce mois génère 20% de plus que la moyenne"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8⃣  BONNES PRATIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] TOUJOURS valider les hypothèses avec un test statistique
   -> "Les remises augmentent les quantités" n'est pas une opinion, c'est une H1

[OK] TOUJOURS contextualiser les chiffres
   -> "ROI de 120%" n'a de sens que si on définit le coût et le gain

[OK] PRÉSENTER les limites de l'analyse
   -> Un modèle de prévision ±15% est honnête. ±0% est suspect.

[OK] UTILISER des intervalles de confiance, pas des valeurs ponctuelles
   -> "CA prévu : 120 000 €" -> "CA prévu : 102 000 – 138 000 €"

[OK] SÉPARER les décisions tactiques (court terme) des stratégiques (long terme)
   -> Relancer les dormants = tactique. Repenser la politique de remises = stratégie.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9⃣  ERREURS FRÉQUENTES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ERREUR 1 : Oublier de normaliser avant K-Means
  [X]  km = KMeans(n_clusters=4).fit(rfm[["recency","frequency","monetary"]])
      -> Recency (en jours) domine le clustering (grande plage de valeurs)
      -> Monetary (en €) est ignoré (même si plus important métier)

  [OK]  rfm_scaled = StandardScaler().fit_transform(rfm[...])
      km = KMeans(n_clusters=4).fit(rfm_scaled)

ERREUR 2 : Fixer K sans méthode du coude
  [X]  km = KMeans(n_clusters=3)   # Arbitraire
  [OK]  Toujours tester K de 2 à 10 et utiliser inertie + silhouette

ERREUR 3 : Confondre prévision et réalité
  [X]  "Le CA 2024 sera de 1 200 000 €"
  [OK]  "Notre modèle prévoit un CA 2024 de 1 020 000 – 1 380 000 € (±15%)"

ERREUR 4 : Ignorer la saisonnalité dans les prévisions
  [X]  "CA 2024 = CA 2023 × 1.08"  (croissance uniforme -> faux !)
  [OK]  Décomposer tendance + saisonnalité -> prévision mensuelle réaliste

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PARTIE 7
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────

Ex 7.1 : Pour le cas business 1 (remises), calculez le taux de remise moyen
  par segment client (Particulier, PME, Grande entreprise).
  Quel segment bénéficie le plus des remises ?

Ex 7.2 : Pour le cas business 2 (segmentation), ajoutez une colonne "pays_dominant"
  au profil de chaque cluster RFM : quel est le pays le plus représenté ?

── NIVEAU INTERMÉDIAIRE ────────────────────────────────────────────

Ex 7.3 : Testez K=5 pour la segmentation K-Means. Comparez les silhouettes
  K=4 vs K=5. Quels nouveaux sous-segments apparaissent ?

Ex 7.4 : Pour le cas business 3 (prévision), ajoutez une variable binaire
  "est_weekend" dans la régression et vérifiez si les weekends ont un CA
  plus élevé (utiliser les données journalières).

── NIVEAU AVANCÉ ────────────────────────────────────────────────────

Ex 7.5 : Implémentez une prévision avec Prophet (Facebook) :
  pip install prophet
  from prophet import Prophet
  m = Prophet(seasonality_mode='multiplicative')
  m.fit(df_prophet)  # df avec colonnes 'ds' (date) et 'y' (CA)
  future = m.make_future_dataframe(periods=365)
  forecast = m.predict(future)
  Comparez les prévisions Prophet vs votre modèle tendance + saisonnalité.

Ex 7.6 : DÉFI COMPLET — Créez un dashboard interactif avec Streamlit :
  pip install streamlit
  Créez app.py avec :
    - Sélecteur de segment RFM
    - Graphique RFM filtré
    - Tableau clients du segment
    - Bouton export CSV
  Lancez : streamlit run app.py

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CORRIGÉS DÉTAILLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# ── Corrigé Ex 7.1 — Remise par segment ─────────────────────────
import pandas as pd
import numpy as np

df = pd.read_excel("data/real_dataset.xlsx", sheet_name="raw_data")
df["discount_pct"] = df["discount_pct"].fillna(0.0)

analyse = df.groupby("customer_segment", observed=True).agg(
    remise_moyenne  =("discount_pct",  "mean"),
    pct_cmds_remise =(
        "discount_pct",
        lambda x: (x > 0).mean() * 100
    ),
    remise_max      =("discount_pct",  "max"),
    ca_moyen        =("chiffre_affaires_ht" if "chiffre_affaires_ht" in df else "unit_price", "mean"),
).round(4)

print("Analyse remises par segment client :")
print(analyse.sort_values("remise_moyenne", ascending=False))


# ── Corrigé Ex 7.3 — K=4 vs K=5 ────────────────────────────────
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score

rfm = df.groupby("customer_id").agg(
    recency   = ("date", lambda x: (pd.to_datetime(df["date"].max()) - pd.to_datetime(x).max()).days),
    frequency = ("invoice_id", "count"),
    monetary  = ("unit_price", "sum"),  # Approximation
)

scaler = StandardScaler()
rfm_s = scaler.fit_transform(rfm)

for k in [4, 5]:
    km = KMeans(n_clusters=k, random_state=42, n_init=20)
    labels = km.fit_predict(rfm_s)
    sil = silhouette_score(rfm_s, labels)
    print(f"\nK={k} : Silhouette={sil:.4f}")

    rfm_copy = rfm.copy()
    rfm_copy["cluster"] = labels
    profil = rfm_copy.groupby("cluster").agg({
        "recency": "mean", "frequency": "mean", "monetary": "mean"
    }).round(0)
    print(profil)
```

================================================================================
FIN PARTIE 7 — Prochaine étape : Partie 8 — Projet final complet
================================================================================

================================================================================
  [GRAPHIQUE] DataInsight Pro — Plateforme Professionnelle d'Analyse de Données
  PARTIE 8 — PROJET FINAL COMPLET
================================================================================

"L'objectif n'est pas de faire tourner du code. C'est de prendre de meilleures décisions."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONTEXTE MÉTIER FINAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Vous êtes à la fin de votre mission chez EuroShop Commerce.

Le CEO vous demande de consolider TOUT votre travail en un seul pipeline
automatisé qui :
  ① Se lance en une commande : `python main.py`
  ② Charge, nettoie, analyse et visualise les données
  ③ Produit un rapport Markdown destiné au comité exécutif
  ④ Sauvegarde tous les graphiques et résultats dans reports/
  ⑤ Peut être relancé sur de nouvelles données sans modifier le code

LIVRABLE ATTENDU : Un projet professionnel, entièrement autonome, documenté,
que n'importe quel data scientist peut reprendre et faire tourner.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  OBJECTIFS PÉDAGOGIQUES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  [OK] Assembler un pipeline data complet de bout en bout
  [OK] Structurer main.py comme point d'entrée unique et lisible
  [OK] Générer un rapport Markdown automatisé
  [OK] Produire des exports CSV des résultats intermédiaires
  [OK] Rédiger un README professionnel
  [OK] Maîtriser la gestion des erreurs (try/except) en contexte réel

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  IMPLÉMENTATION — main.py (COMPLET)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```python
# main.py
"""
DataInsight Pro — Pipeline Principal EuroShop Commerce.

Ce fichier orchestre l'intégralité du pipeline d'analyse de données.
Il est le SEUL point d'entrée : `python main.py`

Architecture :
  main.py -> DataLoader -> DataCleaner -> Analyste -> Visualiseur -> Rapport

Durée d'exécution estimée : 30-90 secondes selon la machine.
"""

# ═══════════════════════════════════════════════════════════════════
# IMPORTS — Bibliothèques standard
# ═══════════════════════════════════════════════════════════════════

import sys          # Accès au chemin Python et aux erreurs système
import os           # Variables d'environnement, chemins
import time         # Mesurer la durée du pipeline
import json         # Sérialiser les résultats en JSON
import traceback    # Afficher les erreurs complètes
from pathlib import Path  # Gestion des chemins cross-platform
from datetime import datetime  # Horodatage du rapport

# ═══════════════════════════════════════════════════════════════════
# IMPORTS — Nos modules (src/)
# ═══════════════════════════════════════════════════════════════════

# Ajouter src/ au chemin Python pour les imports
# Nécessaire si on exécute main.py depuis la racine du projet
sys.path.insert(0, str(Path(__file__).parent))

from src.utils import (
    configurer_logging,
    afficher_section,
    initialiser_dossiers,
    formater_euros,
    formater_pourcentage,
    horodatage_fichier
)
from src.data_loader import DataLoader
from src.data_cleaning import DataCleaner

# ═══════════════════════════════════════════════════════════════════
# CONFIGURATION GLOBALE — Toutes les constantes en un seul endroit
# ═══════════════════════════════════════════════════════════════════

CONFIG = {
    # Chemins
    "chemin_donnees":    "data/real_dataset.xlsx",
    "dossier_reports":   "reports",
    "dossier_outputs":   "outputs",
    "dossier_logs":      "logs",

    # Paramètres d'analyse
    "date_min":          "2022-01-01",
    "date_max":          "2023-12-31",
    "marge_brute":       0.35,           # 35% de marge brute hypothèse

    # Paramètres K-Means
    "kmeans_k":          4,
    "kmeans_seed":       42,

    # Paramètres rapport
    "titre_rapport":     "Rapport Analytique EuroShop Commerce 2022-2023",
    "auteur_rapport":    "DataInsight Pro — Équipe Data",
}


# ═══════════════════════════════════════════════════════════════════
# PIPELINE STEP 1 : CHARGEMENT
# ═══════════════════════════════════════════════════════════════════

def etape_chargement(logger) -> "pd.DataFrame":
    """
    Étape 1 : Charger les données depuis le fichier Excel.

    Retourne : DataFrame brut chargé
    Lève     : FileNotFoundError si le fichier n'existe pas
    """
    afficher_section("ÉTAPE 1 — CHARGEMENT DES DONNÉES")

    loader = DataLoader(
        CONFIG["chemin_donnees"],
        feuille="raw_data"
    )

    df_brut = loader.charger()
    loader.apercu_rapide(df_brut)

    # Audit initial
    rapport_audit = loader.auditer(df_brut)
    loader.afficher_rapport(rapport_audit)

    # Sauvegarder l'audit en JSON
    chemin_audit = Path(CONFIG["dossier_outputs"]) / "audit_initial.json"
    with open(chemin_audit, "w", encoding="utf-8") as f:
        json.dump(rapport_audit, f, indent=2, ensure_ascii=False, default=str)
    logger.info(f"Audit sauvegardé : {chemin_audit}")

    return df_brut


# ═══════════════════════════════════════════════════════════════════
# PIPELINE STEP 2 : NETTOYAGE
# ═══════════════════════════════════════════════════════════════════

def etape_nettoyage(df_brut, logger) -> "pd.DataFrame":
    """
    Étape 2 : Nettoyer les données et créer les colonnes dérivées.

    Paramètre : df_brut — DataFrame brut issu de l'étape 1
    Retourne  : DataFrame nettoyé et enrichi
    """
    afficher_section("ÉTAPE 2 — NETTOYAGE DES DONNÉES")

    cleaner = DataCleaner()
    df_clean = cleaner.nettoyer(df_brut)

    # Afficher le journal des transformations
    cleaner.afficher_journal()

    # Validation finale
    erreurs = cleaner.valider(df_clean)
    if erreurs:
        logger.warning(f"Problèmes résiduels détectés ({len(erreurs)}) :")
        for err in erreurs:
            logger.warning(f"  {err}")
    else:
        logger.info("[OK] Validation : aucun problème critique résiduel")

    # Sauvegarder le dataset nettoyé
    chemin_clean = Path(CONFIG["dossier_outputs"]) / "dataset_clean.csv"
    df_clean.to_csv(chemin_clean, index=False, encoding="utf-8-sig")
    logger.info(f"Dataset nettoyé sauvegardé : {chemin_clean}")

    return df_clean


# ═══════════════════════════════════════════════════════════════════
# PIPELINE STEP 3 : ANALYSE
# ═══════════════════════════════════════════════════════════════════

def etape_analyse(df_clean, logger) -> dict:
    """
    Étape 3 : Calculer tous les KPIs et analyses.

    Paramètre : df_clean — DataFrame nettoyé
    Retourne  : dict avec tous les résultats d'analyse
    """
    import pandas as pd
    import numpy as np
    from scipy import stats
    from sklearn.cluster import KMeans
    from sklearn.preprocessing import StandardScaler

    afficher_section("ÉTAPE 3 — ANALYSE EXPLORATOIRE")
    resultats = {}

    # ── KPIs Globaux ─────────────────────────────────────────────
    logger.info("Calcul des KPIs globaux...")
    resultats["kpis"] = {
        "nb_transactions":      len(df_clean),
        "nb_clients_uniques":   df_clean["customer_id"].nunique(),
        "nb_produits_uniques":  df_clean["product_name"].nunique(),
        "nb_pays":              df_clean["country"].nunique(),
        "ca_total_ht":          round(df_clean["chiffre_affaires_ht"].sum(), 2),
        "ca_moyen_cmd":         round(df_clean["chiffre_affaires_ht"].mean(), 2),
        "panier_moyen":         round(
            df_clean.groupby("invoice_id")["chiffre_affaires_ht"].sum().mean(), 2
        ),
        "pct_livrees":          round((df_clean["order_status"] == "Livré").mean() * 100, 1),
        "pct_annulees":         round((df_clean["order_status"] == "Annulé").mean() * 100, 1),
        "pct_remisees":         round(df_clean["est_remise"].mean() * 100, 1),
        "remise_moy":           round(df_clean["discount_pct"].mean() * 100, 2),
        "date_debut":           str(df_clean["date"].min().date()),
        "date_fin":             str(df_clean["date"].max().date()),
    }
    logger.info(f"  CA total : {formater_euros(resultats['kpis']['ca_total_ht'])}")
    logger.info(f"  Clients  : {resultats['kpis']['nb_clients_uniques']:,}")

    # ── Analyse par catégorie ─────────────────────────────────────
    logger.info("Analyse par catégorie...")
    df_cat = df_clean.groupby("category", observed=True).agg(
        nb_cmds     =("invoice_id",          "count"),
        ca_total    =("chiffre_affaires_ht", "sum"),
        ca_moyen    =("chiffre_affaires_ht", "mean"),
        nb_clients  =("customer_id",          "nunique"),
        qte_totale  =("quantity",             "sum"),
        prix_moyen  =("unit_price",           "mean"),
    ).round(2).sort_values("ca_total", ascending=False)

    total_ca = df_cat["ca_total"].sum()
    df_cat["part_marche_pct"] = (df_cat["ca_total"] / total_ca * 100).round(1)
    resultats["par_categorie"] = df_cat.reset_index()

    # Sauvegarder
    df_cat.to_csv(
        Path(CONFIG["dossier_outputs"]) / "analyse_categories.csv",
        encoding="utf-8-sig"
    )

    # ── Analyse par pays ──────────────────────────────────────────
    logger.info("Analyse par pays...")
    df_pays = df_clean.groupby("country", observed=True).agg(
        nb_cmds     =("invoice_id",          "count"),
        ca_total    =("chiffre_affaires_ht", "sum"),
        ca_moyen    =("chiffre_affaires_ht", "mean"),
        nb_clients  =("customer_id",          "nunique"),
    ).round(2).sort_values("ca_total", ascending=False)
    df_pays["part_pct"] = (df_pays["ca_total"] / df_pays["ca_total"].sum() * 100).round(1)
    resultats["par_pays"] = df_pays.reset_index()
    df_pays.to_csv(
        Path(CONFIG["dossier_outputs"]) / "analyse_pays.csv",
        encoding="utf-8-sig"
    )

    # ── Évolution temporelle ──────────────────────────────────────
    logger.info("Analyse temporelle...")
    ca_mensuel = (
        df_clean.set_index("date")
        .resample("ME")["chiffre_affaires_ht"]
        .sum()
        .reset_index()
    )
    ca_mensuel.columns = ["date", "ca"]
    ca_mensuel["variation_pct"] = ca_mensuel["ca"].pct_change() * 100
    ca_mensuel["ma3"] = ca_mensuel["ca"].rolling(3, min_periods=1).mean()
    resultats["temporel"] = ca_mensuel
    ca_mensuel.to_csv(
        Path(CONFIG["dossier_outputs"]) / "evolution_temporelle.csv",
        index=False, encoding="utf-8-sig"
    )

    # ── Top 10 produits ───────────────────────────────────────────
    logger.info("Top produits...")
    df_prod = df_clean.groupby("product_name", observed=True).agg(
        ca_total  =("chiffre_affaires_ht", "sum"),
        nb_cmds   =("invoice_id",          "count"),
        qte_vendue=("quantity",            "sum"),
        prix_moyen=("unit_price",          "mean"),
    ).round(2).sort_values("ca_total", ascending=False).head(10)
    resultats["top_produits"] = df_prod.reset_index()
    df_prod.to_csv(
        Path(CONFIG["dossier_outputs"]) / "top_produits.csv",
        encoding="utf-8-sig"
    )

    # ── RFM + K-Means ─────────────────────────────────────────────
    logger.info("Segmentation RFM + K-Means...")
    DATE_REF = df_clean["date"].max() + pd.Timedelta(days=1)

    rfm = df_clean.groupby("customer_id").agg(
        recency   = ("date",                lambda x: (DATE_REF - x.max()).days),
        frequency = ("invoice_id",           "count"),
        monetary  = ("chiffre_affaires_ht",  "sum"),
    ).round(2)

    scaler = StandardScaler()
    rfm_s  = scaler.fit_transform(rfm)

    km = KMeans(
        n_clusters=CONFIG["kmeans_k"],
        random_state=CONFIG["kmeans_seed"],
        n_init=20
    )
    rfm["cluster"] = km.fit_predict(rfm_s)

    profil = rfm.groupby("cluster").agg(
        n          =("recency",   "count"),
        r_moy      =("recency",   "mean"),
        f_moy      =("frequency", "mean"),
        m_moy      =("monetary",  "mean"),
    ).round(2)

    # Nommer les clusters
    def nommer(row):
        r_med = rfm["recency"].median()
        f_med = rfm["frequency"].median()
        m_med = rfm["monetary"].median()
        if row["r_moy"] < r_med and row["f_moy"] >= f_med and row["m_moy"] >= m_med:
            return "Champions"
        elif row["r_moy"] < r_med and row["f_moy"] >= f_med:
            return "Fidèles"
        elif row["r_moy"] < r_med:
            return "Récents"
        elif row["m_moy"] >= m_med:
            return "Dormants à valeur"
        else:
            return "Perdus"

    profil["segment"] = profil.apply(nommer, axis=1)
    mapping = profil["segment"].to_dict()
    rfm["segment"] = rfm["cluster"].map(mapping)

    resultats["rfm"] = rfm.reset_index()
    resultats["profil_clusters"] = profil.reset_index()
    rfm.to_csv(
        Path(CONFIG["dossier_outputs"]) / "segmentation_rfm.csv",
        encoding="utf-8-sig"
    )

    logger.info("[OK] Toutes les analyses terminées")
    return resultats


# ═══════════════════════════════════════════════════════════════════
# PIPELINE STEP 4 : VISUALISATION
# ═══════════════════════════════════════════════════════════════════

def etape_visualisation(df_clean, resultats, logger) -> None:
    """
    Étape 4 : Générer tous les graphiques.
    """
    import matplotlib.pyplot as plt
    import matplotlib.ticker as mticker
    import seaborn as sns
    import numpy as np

    afficher_section("ÉTAPE 4 — GÉNÉRATION DES VISUALISATIONS")

    plt.rcParams.update({
        "figure.dpi": 120, "font.size": 9,
        "axes.spines.top": False, "axes.spines.right": False,
        "axes.grid": True, "grid.alpha": 0.3,
    })

    def sauv(fig, nom):
        p = Path(CONFIG["dossier_reports"]) / f"{nom}.png"
        fig.savefig(p, dpi=120, bbox_inches="tight")
        plt.close(fig)
        logger.info(f"  [SAUVEGARDE] {p.name}")

    kpis  = resultats["kpis"]
    df_c  = resultats["par_categorie"]
    df_p  = resultats["par_pays"]
    df_t  = resultats["temporel"]
    rfm   = resultats["rfm"]

    # ── Fig 1 : Dashboard KPIs ────────────────────────────────────
    fig = plt.figure(figsize=(16, 3), facecolor="#1F4E79")
    metriques = [
        ("CA Total", f"{kpis['ca_total_ht']:,.0f} €"),
        ("Transactions", f"{kpis['nb_transactions']:,}"),
        ("Clients", f"{kpis['nb_clients_uniques']:,}"),
        ("Panier Moyen", f"{kpis['panier_moyen']:,.0f} €"),
        ("Taux Livraison", f"{kpis['pct_livrees']:.1f}%"),
        ("Commandes remisées", f"{kpis['pct_remisees']:.1f}%"),
    ]
    gs = fig.add_gridspec(1, len(metriques), wspace=0.05)
    for i, (label, val) in enumerate(metriques):
        ax = fig.add_subplot(gs[0, i])
        ax.set_facecolor("#2E6DA4"); ax.axis("off")
        ax.text(0.5, 0.65, val, ha="center", va="center",
                fontsize=15, fontweight="bold", color="white", transform=ax.transAxes)
        ax.text(0.5, 0.25, label, ha="center", va="center",
                fontsize=8, color="#B0C4DE", transform=ax.transAxes)
    fig.suptitle("[GRAPHIQUE] DataInsight Pro — EuroShop Commerce 2022-2023",
                  color="white", fontsize=12, y=1.02)
    sauv(fig, "fig_01_kpis")

    # ── Fig 2 : CA par catégorie ──────────────────────────────────
    fig, ax = plt.subplots(figsize=(12, 5))
    df_c_sorted = df_c.sort_values("ca_total", ascending=True)
    n = len(df_c_sorted)
    colors = [plt.cm.Blues(0.35 + 0.65 * i / (n-1)) for i in range(n)]
    barres = ax.barh(df_c_sorted["category"], df_c_sorted["ca_total"],
                     color=colors, edgecolor="white")
    for b, pct in zip(barres, df_c_sorted["part_marche_pct"]):
        ax.text(b.get_width() + 1000, b.get_y() + b.get_height()/2,
                f"{b.get_width():,.0f} € ({pct}%)", va="center", fontsize=8)
    ax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x/1000:.0f}K€"))
    ax.set_xlim(0, df_c_sorted["ca_total"].max() * 1.3)
    ax.set_title("CA par Catégorie de Produit")
    ax.set_xlabel("CA HT")
    plt.tight_layout(); sauv(fig, "fig_02_categories")

    # ── Fig 3 : Évolution temporelle ──────────────────────────────
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
    ax1.bar(df_t["date"], df_t["ca"], color="#2196F3", alpha=0.6,
            label="CA mensuel", width=25)
    ax1.plot(df_t["date"], df_t["ma3"], color="#FF5722", linewidth=2,
             marker="o", ms=3, label="MA 3 mois")
    ax1.set_ylabel("CA HT (€)"); ax1.set_title("Évolution mensuelle du CA")
    ax1.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x/1000:.0f}K€"))
    ax1.legend()

    var = df_t["variation_pct"].fillna(0)
    colors_v = ["#4CAF50" if v >= 0 else "#F44336" for v in var]
    ax2.bar(df_t["date"], var, color=colors_v, alpha=0.8, width=25)
    ax2.axhline(0, color="black", lw=0.8)
    ax2.set_ylabel("Variation MoM (%)"); ax2.set_xlabel("Date")
    ax2.set_title("Variation mensuelle (MoM)")
    ax2.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x:+.0f}%"))
    fig.autofmt_xdate(rotation=30); plt.tight_layout(); sauv(fig, "fig_03_temporel")

    # ── Fig 4 : Carte mondiale simplifiée (barres pays) ──────────
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
    df_ps = df_p.sort_values("ca_total", ascending=True)
    palette_pays = plt.cm.get_cmap("tab10", len(df_ps))
    barres = ax1.barh(df_ps["country"], df_ps["ca_total"],
                      color=[palette_pays(i) for i in range(len(df_ps))],
                      edgecolor="white")
    for b, pct in zip(barres, df_ps["part_pct"]):
        ax1.text(b.get_width() + 500, b.get_y() + b.get_height()/2,
                  f"{pct:.1f}%", va="center", fontsize=8)
    ax1.xaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x/1000:.0f}K€"))
    ax1.set_title("CA par Pays")
    ax1.set_xlim(0, df_ps["ca_total"].max() * 1.2)

    # Heatmap pays × catégorie
    pivot = df_clean.pivot_table(
        values="chiffre_affaires_ht", index="country",
        columns="category", aggfunc="sum", fill_value=0, observed=True
    )
    pivot_n = pivot.div(pivot.sum(axis=1), axis=0) * 100
    sns.heatmap(pivot_n, annot=True, fmt=".0f", cmap="YlOrRd",
                linewidths=0.4, ax=ax2, cbar_kws={"label": "% CA"})
    ax2.set_title("Heatmap CA : % par Pays et Catégorie")
    ax2.set_xticklabels(ax2.get_xticklabels(), rotation=30, ha="right", fontsize=7)
    plt.tight_layout(); sauv(fig, "fig_04_pays")

    # ── Fig 5 : Segmentation RFM ──────────────────────────────────
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
    palette_seg = {"Champions":"#FFD700","Fidèles":"#4CAF50",
                   "Récents":"#2196F3","Dormants à valeur":"#FF9800","Perdus":"#9E9E9E"}
    for seg, grp in rfm.groupby("segment"):
        ax1.scatter(grp["recency"], grp["monetary"], alpha=0.45, s=18,
                    color=palette_seg.get(seg, "gray"), label=seg)
    ax1.set_xlabel("Récence (jours)"); ax1.set_ylabel("CA Total (€)")
    ax1.set_title("Récence vs Valeur par Segment RFM"); ax1.legend(fontsize=8)
    ax1.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x:,.0f}€"))

    counts = rfm["segment"].value_counts()
    ax2.pie(counts.values, labels=counts.index,
            colors=[palette_seg.get(s, "#999") for s in counts.index],
            autopct="%1.1f%%", startangle=90, pctdistance=0.8,
            textprops={"fontsize": 8})
    ax2.set_title("Répartition des segments RFM")
    plt.tight_layout(); sauv(fig, "fig_05_rfm")

    logger.info("[OK] 5 figures générées")


# ═══════════════════════════════════════════════════════════════════
# PIPELINE STEP 5 : RAPPORT MARKDOWN
# ═══════════════════════════════════════════════════════════════════

def etape_rapport(df_clean, resultats, logger) -> None:
    """
    Étape 5 : Générer le rapport Markdown final.
    """
    afficher_section("ÉTAPE 5 — GÉNÉRATION DU RAPPORT FINAL")

    kpis    = resultats["kpis"]
    df_c    = resultats["par_categorie"]
    df_pays = resultats["par_pays"]
    profil  = resultats["profil_clusters"]
    df_t    = resultats["temporel"]
    rfm     = resultats["rfm"]

    # YoY
    df_t["annee"] = df_t["date"].dt.year
    ca_2022 = df_t[df_t["annee"] == 2022]["ca"].sum()
    ca_2023 = df_t[df_t["annee"] == 2023]["ca"].sum()
    growth = (ca_2023 - ca_2022) / ca_2022 * 100 if ca_2022 > 0 else 0

    top3_cat = df_c.head(3)
    top3_pays = df_pays.head(3)

    rapport = f"""# {CONFIG['titre_rapport']}

> **Auteur :** {CONFIG['auteur_rapport']}
> **Date :** {datetime.now().strftime('%d/%m/%Y à %H:%M')}
> **Période couverte :** {kpis['date_debut']} -> {kpis['date_fin']}

---

## [LISTE] Résumé Exécutif

Ce rapport présente l'analyse complète des données transactionnelles d'EuroShop Commerce
pour la période 2022-2023. Il répond aux trois questions stratégiques du comité exécutif.

---

## [GRAPHIQUE] KPIs Clés

| Indicateur | Valeur |
|---|---|
| **Chiffre d'Affaires Total HT** | {kpis['ca_total_ht']:,.0f} € |
| **Nombre de Transactions** | {kpis['nb_transactions']:,} |
| **Clients Uniques** | {kpis['nb_clients_uniques']:,} |
| **Panier Moyen** | {kpis['panier_moyen']:,.0f} € |
| **CA Moyen par Commande** | {kpis['ca_moyen_cmd']:,.0f} € |
| **Taux de Livraison** | {kpis['pct_livrees']:.1f}% |
| **Taux d'Annulation** | {kpis['pct_annulees']:.1f}% |
| **Commandes avec Remise** | {kpis['pct_remisees']:.1f}% |
| **Remise Moyenne** | {kpis['remise_moy']:.2f}% |
| **Pays Couverts** | {kpis['nb_pays']} |

---

## [PACKAGE] Performance par Catégorie

Le CA est concentré sur les 3 premières catégories qui représentent
**{top3_cat['part_marche_pct'].sum():.0f}%** du CA total.

| Rang | Catégorie | CA Total | Part Marché | Nb. Commandes |
|---|---|---|---|---|
"""
    for _, r in df_c.head(8).iterrows():
        rang = int(df_c[df_c["category"] == r["category"]].index[0]) + 1
        rapport += f"| {rang} | **{r['category']}** | {r['ca_total']:,.0f} € | {r['part_marche_pct']:.1f}% | {int(r['nb_cmds']):,} |\n"

    rapport += f"""
**Insight :** La catégorie **{df_c.iloc[0]['category']}** domine avec {df_c.iloc[0]['part_marche_pct']:.1f}% du CA.
Les produits à forte valeur unitaire (électronique, équipement sport) génèrent
l'essentiel du chiffre d'affaires malgré des volumes modérés.

---

## [MONDE] Performance Géographique

| Rang | Pays | CA Total | Part | Clients |
|---|---|---|---|---|
"""
    for i, (_, r) in enumerate(df_pays.head(6).iterrows()):
        rapport += f"| {i+1} | **{r['country']}** | {r['ca_total']:,.0f} € | {r['part_pct']:.1f}% | {int(r['nb_clients']):,} |\n"

    rapport += f"""
**Insight :** La **{df_pays.iloc[0]['country']}** représente {df_pays.iloc[0]['part_pct']:.1f}% du CA (marché domestique dominant).
Les 3 premiers pays concentrent {df_pays.head(3)['part_pct'].sum():.0f}% du CA total.

---

## [HAUSSE] Évolution Temporelle

| Exercice | CA Annuel | Croissance |
|---|---|---|
| 2022 | {ca_2022:,.0f} € | — |
| 2023 | {ca_2023:,.0f} € | {growth:+.1f}% |

**Tendance :** La progression annuelle de **{growth:.1f}%** confirme la croissance soutenue
de l'activité. La saisonnalité montre des pics sur certains mois (à exploiter
pour la planification stocks et effectifs).

---

## [UTILISATEURS] Segmentation Clients RFM

Le modèle K-Means (K={CONFIG['kmeans_k']}) identifie **{CONFIG['kmeans_k']} segments** de clients :

| Segment | Clients | % | Récence Moy. | Fréq. Moy. | CA Moy. |
|---|---|---|---|---|---|
"""
    for _, r in profil.sort_values("m_moy", ascending=False).iterrows():
        rapport += (f"| **{r['segment']}** | {int(r['n']):,} | "
                    f"{r['n']/len(rfm)*100:.1f}% | "
                    f"{r['r_moy']:.0f} j | "
                    f"{r['f_moy']:.1f} | "
                    f"{r['m_moy']:,.0f} € |\n")

    rapport += f"""
**Insight :** La loi de Pareto s'applique : les segments "Champions" et "Fidèles"
génèrent la majorité du CA. Prioriser leur rétention avant la prospection.

---

## [IDEE] Recommandations Stratégiques

### 1⃣ Court Terme (0-3 mois)

- **Relancer les clients "Dormants à valeur"** : campagne email avec offre limitée
- **Optimiser les remises** : les concentrer sur les catégories à fort uplift
- **Renforcer le stock** sur les mois de pic identifiés par la saisonnalité

### 2⃣ Moyen Terme (3-12 mois)

- **Programme fidélité Champions** : accès VIP, avant-premières produits
- **Expansion géographique** : les pays sous-représentés (Pologne, Portugal)
  présentent un potentiel de croissance
- **Automatisation de l'analyse** : mettre ce pipeline en production mensuelle

### 3⃣ Long Terme (12+ mois)

- **Modèle prédictif CLV** : anticiper la valeur vie de chaque client
- **Personnalisation catalogue** : adapter les recommendations par segment
- **Intégration ML** : recommandation de produits basée sur l'historique

---

## [ATTENTION] Limites de l'Analyse

- Données simulées à partir de distributions réalistes — les valeurs exactes
  sont indicatives, les patterns sont reproductibles sur données réelles
- La prévision ±15% suppose une continuité des tendances sans choc exogène
- L'analyse RFM ne prend pas en compte la saisonnalité dans les scores

---

## [DOSSIER] Fichiers Produits

| Fichier | Description |
|---|---|
| `outputs/audit_initial.json` | Rapport de qualité des données brutes |
| `outputs/dataset_clean.csv` | Dataset nettoyé (12 000 lignes) |
| `outputs/analyse_categories.csv` | KPIs par catégorie |
| `outputs/analyse_pays.csv` | KPIs par pays |
| `outputs/evolution_temporelle.csv` | CA mensuel 2022-2023 |
| `outputs/top_produits.csv` | Top 10 produits |
| `outputs/segmentation_rfm.csv` | Segments RFM par client |
| `reports/fig_01_kpis.png` | Dashboard KPIs |
| `reports/fig_02_categories.png` | CA par catégorie |
| `reports/fig_03_temporel.png` | Évolution temporelle |
| `reports/fig_04_pays.png` | Performance géographique |
| `reports/fig_05_rfm.png` | Segmentation clients |

---

*Rapport généré automatiquement par DataInsight Pro — Pipeline Python*
*Reproductible : `python main.py`*
"""

    chemin_rapport = Path(CONFIG["dossier_reports"]) / "final_report.md"
    with open(chemin_rapport, "w", encoding="utf-8") as f:
        f.write(rapport)

    logger.info(f"[OK] Rapport sauvegardé : {chemin_rapport}")
    logger.info(f"   Taille : {chemin_rapport.stat().st_size / 1024:.1f} Ko")


# ═══════════════════════════════════════════════════════════════════
# POINT D'ENTRÉE PRINCIPAL
# ═══════════════════════════════════════════════════════════════════

def main():
    """
    Orchestre le pipeline complet DataInsight Pro.
    """
    # 0. Configuration
    logger = configurer_logging(
        niveau="INFO",
        fichier_log=f"logs/run_{horodatage_fichier()}.log"
    )
    initialiser_dossiers([
        CONFIG["dossier_reports"],
        CONFIG["dossier_outputs"],
        CONFIG["dossier_logs"],
    ])

    debut_total = time.perf_counter()
    print(f"""
╔══════════════════════════════════════════════════════════════════╗
║   DataInsight Pro — EuroShop Commerce Analysis Pipeline         ║
║   Lancement : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}                         ║
╚══════════════════════════════════════════════════════════════════╝
""")

    try:
        # ── Étape 1 : Chargement ─────────────────────────────────
        t1 = time.perf_counter()
        df_brut = etape_chargement(logger)
        logger.info(f"[TEMPS] Chargement : {time.perf_counter()-t1:.2f}s")

        # ── Étape 2 : Nettoyage ───────────────────────────────────
        t2 = time.perf_counter()
        df_clean = etape_nettoyage(df_brut, logger)
        logger.info(f"[TEMPS] Nettoyage  : {time.perf_counter()-t2:.2f}s")

        # ── Étape 3 : Analyse ─────────────────────────────────────
        t3 = time.perf_counter()
        resultats = etape_analyse(df_clean, logger)
        logger.info(f"[TEMPS] Analyse    : {time.perf_counter()-t3:.2f}s")

        # ── Étape 4 : Visualisation ───────────────────────────────
        t4 = time.perf_counter()
        etape_visualisation(df_clean, resultats, logger)
        logger.info(f"[TEMPS] Visuels    : {time.perf_counter()-t4:.2f}s")

        # ── Étape 5 : Rapport ─────────────────────────────────────
        t5 = time.perf_counter()
        etape_rapport(df_clean, resultats, logger)
        logger.info(f"[TEMPS] Rapport    : {time.perf_counter()-t5:.2f}s")

        # ── Résumé final ──────────────────────────────────────────
        duree_totale = time.perf_counter() - debut_total
        kpis = resultats["kpis"]

        print(f"""
╔══════════════════════════════════════════════════════════════════╗
║   [OK] PIPELINE TERMINÉ EN {duree_totale:.1f}s                              ║
╠══════════════════════════════════════════════════════════════════╣
║                                                                  ║
║   CA Total     : {kpis['ca_total_ht']:>12,.0f} €                           ║
║   Transactions : {kpis['nb_transactions']:>12,}                              ║
║   Clients      : {kpis['nb_clients_uniques']:>12,}                              ║
║                                                                  ║
║   [DOSSIER] reports/final_report.md   <- Rapport exécutif              ║
║   [DOSSIER] outputs/*.csv             <- Données analysées             ║
║   [DOSSIER] reports/fig_*.png         <- Graphiques                    ║
║                                                                  ║
╚══════════════════════════════════════════════════════════════════╝
""")
        return 0   # Code retour 0 = succès

    except FileNotFoundError as e:
        logger.error(f"[X] FICHIER MANQUANT : {e}")
        logger.error("Vérifiez que data/real_dataset.xlsx est bien présent")
        return 1

    except Exception as e:
        logger.error(f"[X] ERREUR INATTENDUE : {e}")
        logger.error(traceback.format_exc())
        return 2


if __name__ == "__main__":
    code_retour = main()
    sys.exit(code_retour)
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  EXPLICATION LIGNE PAR LIGNE — CONCEPTS CLÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LIGNE : sys.path.insert(0, str(Path(__file__).parent))
  sys.path         -> liste des dossiers où Python cherche les modules
  .insert(0, ...)  -> ajouter EN PREMIER le dossier du projet
  Path(__file__)   -> chemin absolu du fichier main.py lui-même
  .parent          -> dossier parent (racine du projet)
  Résultat : Python peut trouver "from src.utils import ..." depuis n'importe où

LIGNE : try: ... except FileNotFoundError as e: ... except Exception as e:
  try              -> "essaie d'exécuter ce bloc"
  except FileNotFoundError -> intercepte UNIQUEMENT les erreurs de fichier manquant
  except Exception -> intercepte TOUTES les autres erreurs
  Hiérarchie : FileNotFoundError est une sous-classe d'Exception
               -> Toujours mettre les sous-classes EN PREMIER
  as e             -> stocker l'exception dans la variable e

LIGNE : sys.exit(code_retour)
  sys.exit(0)  -> termine le programme avec le code 0 (succès)
  sys.exit(1)  -> termine avec le code 1 (erreur connue)
  sys.exit(2)  -> termine avec le code 2 (erreur inconnue)
  Ces codes sont utilisés par les scripts shell/CI pour détecter les échecs

LIGNE : with open(chemin_rapport, "w", encoding="utf-8") as f: f.write(rapport)
  with open(...) as f  -> context manager : ferme automatiquement le fichier
                          même si une erreur survient
  "w"                  -> mode écriture (écrase si le fichier existe)
  encoding="utf-8"     -> encodage pour les caractères accentués (français)
  f.write(rapport)     -> écrire la chaîne dans le fichier

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  README.md PROFESSIONNEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

```markdown
# [GRAPHIQUE] DataInsight Pro — EuroShop Commerce

Plateforme professionnelle d'analyse de données transactionnelles.

## [PACKAGE] Prérequis

- Python 3.11+
- pip

## [RAPIDE] Installation

```bash
git clone <url_du_repo>
cd python_data_project

# Créer et activer l'environnement virtuel
python -m venv venv
source venv/bin/activate  # Linux/Mac
venv\Scripts\activate     # Windows

# Installer les dépendances
pip install -r requirements.txt
```

## [BLACK_RIGHT-POINTING_TRIANGLE] Lancement

```bash
python main.py
```

Le pipeline s'exécute en ~30-90 secondes et produit :
- `reports/final_report.md`  -> Rapport exécutif Markdown
- `reports/fig_*.png`        -> 5 graphiques professionnels
- `outputs/*.csv`            -> Données analysées exportées
- `logs/run_*.log`           -> Journal d'exécution

## [DOSSIER] Structure

```
python_data_project/
├── data/real_dataset.xlsx    <- Dataset original (ne pas modifier)
├── src/
│   ├── data_loader.py        <- Chargement données
│   ├── data_cleaning.py      <- Nettoyage pipeline
│   ├── utils.py              <- Utilitaires
├── reports/                  <- Graphiques et rapport Markdown
├── outputs/                  <- CSV des résultats
├── logs/                     <- Logs d'exécution horodatés
├── main.py                   <- Point d'entrée unique
└── requirements.txt
```

## [GRAPHIQUE] Dataset

Source : EuroShop Commerce — 12 000 transactions 2022-2023
Inspiré de l'UCI Online Retail II Dataset (open data)
-> https://archive.ics.uci.edu/dataset/502/online+retail+ii

Colonnes : invoice_id, customer_id, date, country, region, category,
product_name, unit_price, quantity, discount_pct, payment_method,
customer_segment, order_status

## [ANALYSE] Analyses Produites

1. **KPIs globaux** : CA, transactions, clients, panier moyen
2. **Performance catégories** : CA et parts de marché par catégorie
3. **Géographie** : heatmap pays × catégorie
4. **Temporalité** : tendance, saisonnalité, variation MoM
5. **Segmentation RFM** : K-Means 4 clusters avec nommage métier

## [BOITE_A_OUTILS] Technologies

| Outil | Usage |
|---|---|
| pandas 2.x | Manipulation données |
| matplotlib + seaborn | Visualisation |
| scikit-learn | K-Means, normalisation |
| scipy | Tests statistiques |
| openpyxl | Lecture Excel |

## [FICHIER] Licence

Projet éducatif — Usage libre pour apprentissage.
```

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[10]  EXERCICES PARTIE 8 — PROJET FINAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

── NIVEAU FACILE ──────────────────────────────────────────────────

Ex 8.1 : Lancez `python main.py` et vérifiez que :
  - Tous les fichiers sont créés dans reports/ et outputs/
  - Le rapport final_report.md s'ouvre correctement
  - Les 5 graphiques sont générés

Ex 8.2 : Ajoutez dans CONFIG un paramètre "top_n_produits": 15
  et modifiez etape_analyse() pour en tenir compte dynamiquement.

── NIVEAU INTERMÉDIAIRE ────────────────────────────────────────────

Ex 8.3 : Ajoutez une 6ème figure (fig_06) dans etape_visualisation() :
  Graphique à barres du CA par mode de paiement et par année (barres groupées).

Ex 8.4 : Ajoutez une étape 6 "etape_export_excel()" qui crée un fichier
  Excel formaté dans outputs/ avec 4 feuilles : KPIs, Catégories, Pays, RFM.
  Utilisez openpyxl pour le formatage (en-têtes colorés, colonnes auto-dimensionnées).

── NIVEAU AVANCÉ ────────────────────────────────────────────────────

Ex 8.5 : Ajoutez des arguments en ligne de commande avec argparse :
  python main.py --date-debut 2022-06-01 --date-fin 2022-12-31 --pays France
  -> Le pipeline ne traite que les données filtrées sur ces paramètres.

Ex 8.6 : DÉFI ULTIME — Créez une interface Streamlit (app.py) qui :
  1. Affiche les KPIs dans des "metric cards"
  2. Permet de sélectionner un pays et une catégorie via des selectbox
  3. Recharge dynamiquement tous les graphiques selon la sélection
  4. Affiche le tableau des top produits filtré
  Lancez : streamlit run app.py

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣1⃣  CONCLUSION — CE QUE VOUS AVEZ MAÎTRISÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En complétant les 8 parties de DataInsight Pro, vous maîtrisez maintenant :

COMPÉTENCES TECHNIQUES :
  [OK] Architecturer un projet data (src/, data/, reports/, outputs/, logs/)
  [OK] Charger et inspecter des données Excel réelles (pandas, openpyxl)
  [OK] Nettoyer un dataset avec un pipeline documenté et traçable
  [OK] Créer des colonnes dérivées (CA, dates, tranches)
  [OK] Calculer des KPIs business (CA, panier moyen, taux livraison)
  [OK] Analyser par groupes (groupby, agg, pivot_table)
  [OK] Visualiser professionnellement (matplotlib, seaborn, subplots, formatage)
  [OK] Réaliser des tests statistiques (Mann-Whitney, ANOVA, chi-carré)
  [OK] Segmenter des clients (RFM + K-Means)
  [OK] Prévoir un CA (tendance linéaire + indices saisonniers)
  [OK] Générer un rapport automatisé (Markdown)
  [OK] Gérer les erreurs (try/except, logging)

COMPÉTENCES MÉTIER :
  [OK] Traduire une question business en requête analytique
  [OK] Produire des insights chiffrés et actionnables
  [OK] Rédiger des recommandations avec justification data
  [OK] Présenter des résultats à un comité exécutif

BONNES PRATIQUES PROFESSIONNELLES :
  [OK] SOLID : chaque module = une responsabilité unique
  [OK] DRY : pas de code dupliqué, fonctions réutilisables
  [OK] Documenté : docstrings, README, rapport
  [OK] Reproductible : random_state, requirements.txt, CONFIG centralisée
  [OK] Robuste : gestion des erreurs, validation des données

PROCHAINES ÉTAPES SUGGÉRÉES :
  -> Approfondir scikit-learn (pipelines ML complets)
  -> Explorer Apache Spark pour les grands volumes (>100M lignes)
  -> Apprendre SQL pour requêter des bases de données
  -> Découvrir dbt pour les pipelines de données en production
  -> Certifications : AWS Data Analytics, Google Data Engineer

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DATASET RÉEL — LIENS ET RESSOURCES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

DATASET INSPIRANT CE PROJET :
  UCI Online Retail II : https://archive.ics.uci.edu/dataset/502/online+retail+ii
  -> 1 million de transactions e-commerce britanniques 2009-2011
  -> Dataset réel, utilisé dans des centaines de publications académiques

DATASETS COMPLÉMENTAIRES POUR S'EXERCER :
  Kaggle E-Commerce  : https://www.kaggle.com/datasets/carrie1/ecommerce-data
  Brazilian E-Com    : https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce
  Instacart Orders   : https://www.kaggle.com/competitions/instacart-market-basket-analysis
  Superstore Sales   : https://www.kaggle.com/datasets/vivek468/superstore-dataset-final

ALLER PLUS LOIN :
  Scikit-learn docs  : https://scikit-learn.org/stable/user_guide.html
  Pandas cookbook    : https://pandas.pydata.org/docs/user_guide/cookbook.html
  Seaborn gallery    : https://seaborn.pydata.org/examples/index.html
  Real Python        : https://realpython.com/pandas-dataframe/

================================================================================
  [OK] PROJET DATAINSIGHT PRO — COMPLET (8/8 PARTIES)
  
  Récapitulatif des livrables :
  • project_part_1.txt  — Setup & découverte dataset       (~950 lignes)
  • project_part_2.txt  — Chargement avancé                (~780 lignes)
  • project_part_3_4.txt— Nettoyage + EDA                  (~980 lignes)
  • project_part_5_6.txt— Visualisation + Analyse avancée  (~850 lignes)
  • project_part_7.txt  — Cas business réels               (~800 lignes)
  • project_part_8.txt  — Projet final complet             (~700 lignes)
  
  Total : ~5 060 lignes de contenu pédagogique
  + data/real_dataset.xlsx (12 000 lignes, 989 Ko)
================================================================================

Selon vous avec ces cours et ces projets, on peut prétendre être DATA ANALYSTE SENIOR?

Honnêtement, non — et je pense que vous méritez une réponse franche plutôt qu'encourageante.

---

## Ce que ces cours et projets vous donnent réellement

**Un excellent point de départ.** Vous avez couvert la stack technique de base (pandas, matplotlib, sklearn, tests stats), vous savez structurer un projet, et vous comprenez les concepts fondamentaux. C'est solide pour un débutant.

Mais "data analyste senior" implique des dimensions que aucun cours ne peut transmettre.

---

## Ce qui manque — et pourquoi c'est irremplaçable

**L'expérience réelle avec des données sales**

Les vraies données d'entreprise sont infiniment plus complexes que notre dataset simulé. Des systèmes legacy incohérents, des définitions métier qui changent en cours de projet, des colonnes dont personne ne sait plus ce qu'elles signifient. Ça s'apprend uniquement sur le terrain, en se trompant.

**Le jugement métier**

Un analyste senior ne sait pas juste calculer un RFM — il sait QUAND c'est la bonne approche, quand ce n'est pas pertinent, et comment l'expliquer à un directeur qui n'a pas de background technique. Ça demande des années de cycles erreur -> feedback -> correction.

**La maîtrise des outils professionnels**

SQL est souvent plus important que Python dans les entreprises. Les entrepôts de données (BigQuery, Snowflake, Redshift), les outils BI (Tableau, Power BI, Looker), git en équipe, les environnements cloud (AWS, GCP) — tout ça ne figure pas dans nos cours.

**La résistance à la pression**

Livrer une analyse correcte en 4 heures parce que le CODIR en a besoin pour ce soir. Défendre ses chiffres quand le directeur commercial dit "ça ne correspond pas à ce qu'on ressent". Gérer les données manquantes sur un projet critique en production. Ça ne s'apprend pas dans un cours.

**La communication**

Un senior transforme des résultats complexes en décisions claires pour des non-techniciens. C'est une compétence qui se développe uniquement en faisant de vraies présentations à de vrais décideurs, avec les frictions que ça implique.

---

## Ce que vous pouvez prétendre honnêtement

Avec ce niveau, vous êtes légitime pour postuler en tant que :

- **Data analyste junior** dans une entreprise avec une équipe data existante qui peut vous encadrer
- **Analyste BI junior** si vous ajoutez SQL et un outil de visualisation (Power BI ou Tableau)
- **Assistant data scientist** dans un contexte d'apprentissage structuré

---

## Le chemin réaliste vers "senior"

En partant de zéro avec votre niveau actuel, comptez :

**6-12 mois** pour décrocher un premier poste junior (en ajoutant SQL sérieusement, un projet Kaggle publié, GitHub actif)

**2-3 ans** d'expérience réelle pour être un analyste autonome et compétent

**4-6 ans** minimum pour être légitime au niveau senior — c'est-à-dire capable de définir la stratégie data d'une équipe, pas seulement d'exécuter des analyses

---

## Ce que je vous recommande concrètement

1. **Maîtriser SQL** — c'est la compétence la plus demandée et la plus sous-estimée. Mode.ly, SQLZoo, puis des vraies requêtes sur PostgreSQL
2. **Publier un vrai projet** sur GitHub avec un dataset public réel (Kaggle, data.gouv.fr) et une analyse complète documentée
3. **Postuler junior sans complexe** — l'expérience en entreprise vaut 10× n'importe quel cours
4. **Trouver un mentor** — quelqu'un qui travaille déjà dans la data et peut relire votre code et vos analyses

Ces cours sont un très bon investissement. Mais ils sont le commencement du chemin, pas la destination.